UNPKG

glov-build

Version:
455 lines (423 loc) 14.1 kB
exports.filesCreate = filesCreate; exports.Files = Files; exports.isBuildFile = isBuildFile; exports.BuildFile = BuildFile; exports.filesGetBucket = filesGetBucket; const DELETED_TIMESTAMP = exports.DELETED_TIMESTAMP = -1; const assert = require('assert'); const fs = require('fs'); const micromatch = require('micromatch'); const path = require('path'); const { asyncLoader, forwardSlashes, deleteFileWithRmdir, writeFileWithMkdir } = require('./util.js'); // This is not the exclusive error if the file does not exist, only if it was pruned by us const ERR_DOES_NOT_EXIST = 'ERR_DOES_NOT_EXIST'; const ERR_CASE_MISMATCH = 'ERR_CASE_MISMATCH'; // Aim for roughly Vinyl-compatible? function BuildFile(files, bucket_name, relative) { assert(bucket_name); assert(relative); assert(relative.indexOf(':') === -1); this.files = files; this.bucket = bucket_name; this.relative = relative; // relative path from bucket let bucket_dir = files.bucket_dirs[bucket_name]; assert(bucket_dir); this.disk_path = forwardSlashes(path.join(bucket_dir, relative)); this.key = `${bucket_name}:${relative}`; this.contents = null; this.timestamp = 0; // TODO populate on read (from glob/filesystem search) this.err = null; // this.base = bucket_dir; // for Vinyl support // this.cwd = bucket_dir; // for Vinyl support // this.path = this.disk_path; // for Vinyl support // this.stat } function isBuildFile(file) { return file instanceof BuildFile; } BuildFile.prototype.serializeForFork = function () { return { bucket: this.bucket, relative: this.relative, contents: this.contents, timestamp: this.timestamp, err: this.err, }; }; BuildFile.prototype.deserializeForFork = function (data) { assert.equal(this.bucket, data.bucket); assert.equal(this.relative, data.relative); this.contents = data.contents; this.timestamp = data.timestamp; this.err = data.err; }; BuildFile.prototype.getDiskPath = function () { return this.disk_path; }; BuildFile.prototype.getBucketDir = function () { return this.files.getBucketDir(this.bucket); }; BuildFile.prototype.toVinyl = function () { let bucket_dir = this.files.bucket_dirs[this.bucket]; return { cwd: bucket_dir, base: bucket_dir, path: this.disk_path, contents: this.contents, }; }; // BuildFile.prototype.isBuffer = function () { // for Vinyl support // return true; // }; // BuildFile.prototype.isNull = function () { // for Vinyl support // return false; // }; // BuildFile.prototype.isStream = function () { // for Vinyl support // return false; // }; // BuildFile.prototype.clone = function (opts) { // for Vinyl support // let ret = new BuildFile(this.files, this.bucket, this.relative); // if (Buffer.isBuffer(this.contents)) { // if (opts.contents === false) { // ret.contents = this.contents; // } else { // ret.contents = Buffer.from(this.contents); // } // } else { // ret.contents = this.contents; // } // return ret; // }; BuildFile.prototype.get = function (next) { this.files.get(this.bucket, this.relative, next); }; BuildFile.prototype.getStat = function (next) { this.files.getStat(this.bucket, this.relative, 'BuildFile::getStat', next); }; function Files(gb) { this.gb = gb; this.config = gb.config; this.buckets = Object.create(null); this.bucket_dirs = Object.create(null); this.statter = asyncLoader(statFile.bind(null, this)); this.loader = asyncLoader(readFile.bind(null, this)); this.resetStats(); } Files.prototype.resetStats = function () { this.stats = { read: 0, write: 0, stat: 0, delete: 0, }; }; Files.prototype.addBucket = function (bucket_name, dir) { assert(!this.bucket_dirs[bucket_name]); this.bucket_dirs[bucket_name] = forwardSlashes(dir); }; Files.prototype.getBucketDir = function (bucket_name) { return this.bucket_dirs[bucket_name]; }; function filesGetBucket(files, bucket_name) { let bucket = files.buckets[bucket_name]; if (!bucket) { bucket = files.buckets[bucket_name] = Object.create(null); } return bucket; } function diskPath(files, file) { assert(file.disk_path); return file.disk_path; } Files.prototype.getDiskPath = function (bucket_name, relative) { let bucket_dir = this.bucket_dirs[bucket_name]; assert(bucket_dir); return forwardSlashes(path.join(bucket_dir, relative)); }; function statFile(files, file, next) { if (file.timestamp) { // already loaded, probably output from previous task return next(); } let disk_path = diskPath(files, file); files.stats.stat++; files.gb.silly(` Statting ${disk_path}...`); fs.stat(disk_path, function (err, stats) { if (err) { // Probably don't want this log, happens when any input is removed under // expected circumstances - tasks should error later anyway. // files.gb.debug(`Could not stat file ${disk_path} for ${file.get_stat_for_who}:`, err); file.err = err; file.timestamp = DELETED_TIMESTAMP; assert(!file.contents); return next(file.err); } if (stats.isDirectory()) { file.err = 'EISDIR'; file.timestamp = DELETED_TIMESTAMP; assert(!file.contents); return next(file.err); } fs.realpath.native(disk_path, (err, real_path) => { if (err) { // unexpected - race condition? files.gb.warn(`Could not get realpath of ${disk_path} for ${file.get_stat_for_who}:`, err); file.err = err; file.timestamp = DELETED_TIMESTAMP; } else { real_path = forwardSlashes(real_path); if (!real_path.endsWith(disk_path)) { file.err = ERR_CASE_MISMATCH; file.timestamp = DELETED_TIMESTAMP; } else { file.err = null; file.timestamp = stats.mtime.getTime(); } } next(file.err); }); }); } function readFileWithRetries(files, disk_path, next) { let retries = 0; function doAttempt() { fs.readFile(disk_path, function (err, buf) { if (!err) { return next(err, buf); } if (err.code === 'ENOENT') { // file no longer exists, immediately fail files.gb.debug(`File disappeared while reading "${disk_path}"`); return next(ERR_DOES_NOT_EXIST, buf); } ++retries; let delay; let max_retries; if (err.code === 'EBUSY') { // someone else is still actively writing this file, try again repeatedly until // we get access delay = 100; max_retries = 150; // 100ms each, 15s total files.gb[retries % 5 ? 'debug' : 'info'](`File "${disk_path}" busy, retrying (${retries})...`); } else { delay = retries * retries * 100; max_retries = 5; // last delay of 2500ms files.gb.debug(`Error reading file "${disk_path}": ${err.code || err}, retrying (${retries})...`); } if (retries >= max_retries) { // out of retries files.gb.info(`Retries (${retries}) exhausted while reading "${disk_path}"`); return next(err, buf); } setTimeout(doAttempt, delay); }); } doAttempt(); } function readFile(files, file, next) { // TODO: if we get an error here, or in statFile, but the file gets modified, need to clear the .loader/.statter cache files.statter(file, function (err) { if (err) { file.err = err; return next(err); } if (file.contents) { // already loaded, probably output from previous task return next(); } let disk_path = diskPath(files, file); files.gb.silly(` Reading ${disk_path}...`); files.stats.read++; readFileWithRetries(files, disk_path, function (err, buf) { if (err) { file.err = err; file.contents = null; files.gb.error(`Could not read file ${disk_path}:`, err); } else { files.gb.silly(` Read ${disk_path} (${buf.length} bytes)`); file.err = null; file.contents = buf; } next(err); }); }); } function writeFile(files, file, cb) { let disk_path = diskPath(files, file); files.gb.debug(` Writing ${disk_path}...`); files.stats.write++; writeFileWithMkdir(files.gb, disk_path, file.contents, cb); } Files.prototype.getPre = function (bucket_name, relative) { assert(bucket_name); assert(relative); assert(relative.indexOf(':') === -1); let bucket = filesGetBucket(this, bucket_name); let file = bucket[relative]; if (!file) { file = bucket[relative] = new BuildFile(this, bucket_name, relative); } return file; }; Files.prototype.get = function (bucket_name, relative, cb) { assert.equal(typeof cb, 'function'); let file = this.getPre(bucket_name, relative); this.loader(file, cb); }; Files.prototype.getStat = function (bucket_name, relative, for_who, cb) { assert.equal(typeof cb, 'function'); let file = this.getPre(bucket_name, relative); file.get_stat_for_who = for_who; this.statter(file, cb); }; Files.prototype.put = function (opts, cb) { assert(opts.bucket); assert(opts.relative); assert(opts.who); let key = `${opts.bucket}:${opts.relative}`; let { contents } = opts; if (!contents) { return cb(new Error(`File "${key}" missing contents`)); } if (typeof contents === 'string') { contents = Buffer.from(contents); } else if (Buffer.isBuffer(contents)) { // pass through } else { return cb(new Error(`File "${key}" contents must be a Buffer or String`)); } let bucket = filesGetBucket(this, opts.bucket); let file = bucket[opts.relative]; if (file && file.who && file.who !== opts.who) { return cb(new Error(`File "${key}" was already output by ${file.who}, now again by ${opts.who}`)); } if (!file) { file = bucket[opts.relative] = new BuildFile(this, opts.bucket, opts.relative); } file.err = null; file.contents = contents; file.who = opts.who; this.loader.reset(file, true, file.err); if (opts.skip_write) { // Just updating our cache assert(opts.timestamp); file.timestamp = opts.timestamp; this.statter.reset(file, true, file.err); cb(null, file); } else { file.timestamp = Date.now(); // will be at least this, updated below with what gets written to disk writeFile(this, file, (err) => { if (err) { return cb(err); } let disk_path = diskPath(this, file); // this.stats.stat++; // don't count this, for the purpose of tests, this doesn't make sense fs.stat(disk_path, (err, stat) => { if (err) { this.gb.error(`Could not stat file ${disk_path} we just wrote!`, err); return cb(err); } file.timestamp = stat.mtime.getTime(); this.statter.reset(file, true, file.err); cb(null, file); }); }); } }; function pruneFile(files, file, cb) { let disk_path = forwardSlashes(path.join(files.bucket_dirs[file.bucket], file.relative)); files.stats.delete++; files.gb.debug(` Deleting ${disk_path}...`); deleteFileWithRmdir(disk_path, cb); } Files.prototype.fsEventDeleteUseful = function (bucket_name, relative) { let bucket = filesGetBucket(this, bucket_name); let file = bucket[relative]; if (file && file.timestamp === DELETED_TIMESTAMP) { return false; } return true; }; Files.prototype.registerDelete = function (bucket_name, relative) { let bucket = filesGetBucket(this, bucket_name); let file = bucket[relative]; if (file) { if (file.timestamp === DELETED_TIMESTAMP) { return; } file.err = ERR_DOES_NOT_EXIST; file.timestamp = DELETED_TIMESTAMP; file.contents = null; file.who = null; this.statter.reset(file, true, file.err); this.loader.reset(file, true, file.err); } }; Files.prototype.prune = function (bucket_name, relative, cb) { this.registerDelete(bucket_name, relative); pruneFile(this, { bucket: bucket_name, relative: relative }, cb); }; // Returns true if it would do anything Files.prototype.fsEventUseful = function (event, bucket_name, relative, opt_stat) { if (event === 'unlink') { return this.fsEventDeleteUseful(bucket_name, relative); } else if (event === 'change' || event === 'add') { let bucket = filesGetBucket(this, bucket_name); let file = bucket[relative]; if (file) { if (opt_stat && file.timestamp === opt_stat.mtime.getTime()) { return false; } } return true; } else { assert(false, `Unhandled FS event: ${event}`); } return true; }; Files.prototype.fsEvent = function (event, bucket_name, relative, opt_stat) { // TODO: what about getStat()s already in-flight? Will assert for now // Shouldn't be an issue after fsEvent calls are delayed until the build // stabilizes? if (event === 'unlink') { this.registerDelete(bucket_name, relative); } else if (event === 'change' || event === 'add') { // Just reset the cache, we'll stat the disk again if needed let bucket = filesGetBucket(this, bucket_name); let file = bucket[relative]; if (file) { if (opt_stat && file.timestamp === opt_stat.mtime.getTime()) { // Should have been filtered with fsEventUseful, but possibly get hit if // we get a useful change followed by a spurious change, all queued at // once. return; } file.err = null; file.contents = null; if (opt_stat) { file.timestamp = opt_stat.mtime.getTime(); this.statter.reset(file, true); } else { file.timestamp = 0; this.statter.reset(file, false); } this.loader.reset(file, false); } } else { assert(false, `Unhandled FS event: ${event}`); } }; // returns map of file.relative to BuildFile Files.prototype.glob = function (bucket_name, globs) { let bucket = filesGetBucket(this, bucket_name); let ret = micromatch.matchKeys(bucket, globs); for (let key in ret) { if (ret[key].timestamp === DELETED_TIMESTAMP) { // deleted file delete ret[key]; } } return ret; }; function filesCreate(gb) { return new Files(gb); }