Files
meteor/tools/fs/optimistic.js
Ben Newman 0f9c58d6b9 Immediately dirty optimistic functions when relevant paths modified.
Should fix #8988 and #8942.

Now that #8866 is the default behavior, it can take up to 5000ms for
changes to files modified during the build process to be noticed.

Before #8866, when we called e.g. files.writeFile(path), a native file
watcher would notice the change immediately, almost always before the
build process read the file again. This was definitely racy, but we were
getting away with it consistently... until #8866.

I was able to reproduce the problem in #8988 by running

  echo some-local-package-name >> .meteor/packages

in an app with a local package of the given name. After debugging the
endless rebuild cycle, I found that .meteor/versions was being rewritten
by files.writeFile during the build process, but the file watching system
was not noticing the change in time to prevent watch.isUpToDate from
returning true. The change was finally detected when restarting the
Watcher responsible for .meteor/versions, which of course triggered
another rebuild, so the same problem kept happening again and again.
2017-08-11 13:57:16 -04:00

249 lines
6.1 KiB
JavaScript

import assert from "assert";
import { wrap } from "optimism";
import { Profile } from "../tool-env/profile.js";
import { watch } from "./safe-watcher.js";
import { sha1 } from "./watch.js";
import {
pathSep,
pathIsAbsolute,
statOrNull,
lstat,
readFile,
readdir,
dependOnPath,
} from "./files.js";
// When in doubt, the optimistic caching system can be completely disabled
// by setting this environment variable.
const ENABLED = ! process.env.METEOR_DISABLE_OPTIMISTIC_CACHING;
function makeOptimistic(name, fn) {
const wrapper = wrap(ENABLED ? function (...args) {
maybeDependOnPath(args[0]);
return fn.apply(this, args);
} : fn, {
makeCacheKey(...args) {
if (! ENABLED) {
// Cache nothing when the optimistic caching system is disabled.
return;
}
const path = args[0];
if (! pathIsAbsolute(path)) {
return;
}
var parts = [];
for (var i = 0; i < args.length; ++i) {
var arg = args[i];
if (typeof arg !== "string") {
// If any of the arguments is not a string, then we won't cache the
// result of the corresponding file.* method invocation.
return;
}
parts.push(arg);
}
return parts.join("\0");
},
subscribe(...args) {
const path = args[0];
if (! shouldWatch(path)) {
return;
}
assert.ok(pathIsAbsolute(path));
let watcher = watch(path, () => {
wrapper.dirty(...args);
});
return () => {
if (watcher) {
watcher.close();
watcher = null;
}
};
}
});
return Profile("optimistic " + name, wrapper);
}
export const shouldWatch = wrap(path => {
const parts = path.split(pathSep);
const nmi = parts.indexOf("node_modules");
if (nmi < 0) {
// Watch everything not in a node_modules directory.
return true;
}
if (nmi < parts.length - 1) {
const nmi2 = parts.indexOf("node_modules", nmi + 1);
if (nmi2 > nmi) {
// If this path is nested inside more than one node_modules
// directory, then it isn't part of a linked npm package, so we
// should not watch it.
return false;
}
const packageDirParts = parts.slice(0, nmi + 2);
if (parts[nmi + 1].startsWith("@")) {
// For linked @scoped npm packages, the symlink is nested inside the
// @scoped directory (which is a child of node_modules).
packageDirParts.push(parts[nmi + 2]);
}
const packageDir = packageDirParts.join(pathSep);
if (optimisticIsSymbolicLink(packageDir)) {
// If this path is in a linked npm package, then it might be under
// active development, so we should watch it.
return true;
}
}
// Starting a watcher for every single file contained within a
// node_modules directory would be prohibitively expensive, so
// instead we rely on dependOnNodeModules to tell us when files in
// node_modules directories might have changed.
return false;
});
function maybeDependOnPath(path) {
if (typeof path === "string") {
dependOnPath(path);
maybeDependOnNodeModules(path);
}
}
function maybeDependOnNodeModules(path) {
if (typeof path !== "string") {
return;
}
const parts = path.split(pathSep);
while (true) {
const index = parts.lastIndexOf("node_modules");
if (index < 0) {
return;
}
parts.length = index + 1;
dependOnNodeModules(parts.join(pathSep));
assert.strictEqual(parts.pop(), "node_modules");
}
}
let npmDepCount = 0;
// Called by any optimistic function that receives a */node_modules/* path
// as its first argument, so that we can later bulk-invalidate the results
// of those calls if the contents of the node_modules directory change.
// Note that this strategy will not detect changes within subdirectories
// of this node_modules directory, but that's ok because the use case we
// care about is adding or removing npm packages.
const dependOnNodeModules = wrap(nodeModulesDir => {
assert(pathIsAbsolute(nodeModulesDir));
assert(nodeModulesDir.endsWith(pathSep + "node_modules"));
// Always return something different to prevent optimism from
// second-guessing the dirtiness of this function.
return ++npmDepCount;
}, {
subscribe(nodeModulesDir) {
let watcher = watch(
nodeModulesDir,
() => dependOnNodeModules.dirty(nodeModulesDir),
);
return function () {
if (watcher) {
watcher.close();
watcher = null;
}
};
}
});
// Invalidate all optimistic results derived from paths involving the
// given node_modules directory.
export function dirtyNodeModulesDirectory(nodeModulesDir) {
dependOnNodeModules.dirty(nodeModulesDir);
}
export const optimisticStatOrNull = makeOptimistic("statOrNull", statOrNull);
export const optimisticLStat = makeOptimistic("lstat", lstat);
export const optimisticLStatOrNull = makeOptimistic("lstatOrNull", path => {
try {
return optimisticLStat(path);
} catch (e) {
if (e.code !== "ENOENT") throw e;
return null;
}
});
export const optimisticReadFile = makeOptimistic("readFile", readFile);
export const optimisticReaddir = makeOptimistic("readdir", readdir);
export const optimisticHashOrNull = makeOptimistic("hashOrNull", (...args) => {
try {
return sha1(optimisticReadFile(...args));
} catch (e) {
if (e.code !== "EISDIR" &&
e.code !== "ENOENT") {
throw e;
}
}
return null;
});
export const optimisticReadJsonOrNull =
makeOptimistic("readJsonOrNull", (path, options) => {
try {
return JSON.parse(optimisticReadFile(path, options));
} catch (e) {
if (e.code === "ENOENT") {
return null;
}
if (e instanceof SyntaxError &&
options && options.allowSyntaxError) {
return null;
}
throw e;
}
});
const optimisticIsSymbolicLink = wrap(path => {
try {
return lstat(path).isSymbolicLink();
} catch (e) {
if (e.code !== "ENOENT") throw e;
return false;
}
}, {
subscribe(path) {
let watcher = watch(path, () => {
optimisticIsSymbolicLink.dirty(path);
});
return function () {
if (watcher) {
watcher.close();
watcher = null;
}
};
}
});