312 lines
9.5 KiB
Plaintext
312 lines
9.5 KiB
Plaintext
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
* @flow strict-local
|
|
*/
|
|
|
|
import type {
|
|
Console,
|
|
CrawlerOptions,
|
|
FileData,
|
|
Path,
|
|
PerfLogger,
|
|
WatcherBackend,
|
|
WatcherBackendChangeEvent,
|
|
WatchmanClocks,
|
|
} from './flow-types';
|
|
import type {WatcherOptions as WatcherBackendOptions} from './watchers/common';
|
|
|
|
import nodeCrawl from './crawlers/node';
|
|
import watchmanCrawl from './crawlers/watchman';
|
|
import {TOUCH_EVENT} from './watchers/common';
|
|
import FallbackWatcher from './watchers/FallbackWatcher';
|
|
import NativeWatcher from './watchers/NativeWatcher';
|
|
import WatchmanWatcher from './watchers/WatchmanWatcher';
|
|
import EventEmitter from 'events';
|
|
import * as fs from 'fs';
|
|
import nullthrows from 'nullthrows';
|
|
import * as path from 'path';
|
|
import {performance} from 'perf_hooks';
|
|
|
|
// eslint-disable-next-line import/no-commonjs
|
|
const debug = require('debug')('Metro:Watcher');
|
|
|
|
const MAX_WAIT_TIME = 240000;
|
|
|
|
type CrawlResult = {
|
|
changedFiles: FileData,
|
|
clocks?: WatchmanClocks,
|
|
removedFiles: Set<Path>,
|
|
};
|
|
|
|
type WatcherOptions = {
|
|
abortSignal: AbortSignal,
|
|
computeSha1: boolean,
|
|
console: Console,
|
|
enableSymlinks: boolean,
|
|
extensions: $ReadOnlyArray<string>,
|
|
forceNodeFilesystemAPI: boolean,
|
|
healthCheckFilePrefix: string,
|
|
ignoreForCrawl: string => boolean,
|
|
ignorePatternForWatch: RegExp,
|
|
previousState: CrawlerOptions['previousState'],
|
|
perfLogger: ?PerfLogger,
|
|
roots: $ReadOnlyArray<string>,
|
|
rootDir: string,
|
|
useWatchman: boolean,
|
|
watch: boolean,
|
|
watchmanDeferStates: $ReadOnlyArray<string>,
|
|
};
|
|
|
|
let nextInstanceId = 0;
|
|
|
|
export type HealthCheckResult =
|
|
| {type: 'error', timeout: number, error: Error, watcher: ?string}
|
|
| {type: 'success', timeout: number, timeElapsed: number, watcher: ?string}
|
|
| {type: 'timeout', timeout: number, watcher: ?string, pauseReason: ?string};
|
|
|
|
export class Watcher extends EventEmitter {
|
|
_options: WatcherOptions;
|
|
_backends: $ReadOnlyArray<WatcherBackend> = [];
|
|
_instanceId: number;
|
|
_nextHealthCheckId: number = 0;
|
|
_pendingHealthChecks: Map</* basename */ string, /* resolve */ () => void> =
|
|
new Map();
|
|
_activeWatcher: ?string;
|
|
|
|
constructor(options: WatcherOptions) {
|
|
super();
|
|
this._options = options;
|
|
this._instanceId = nextInstanceId++;
|
|
}
|
|
|
|
async crawl(): Promise<CrawlResult> {
|
|
this._options.perfLogger?.point('crawl_start');
|
|
|
|
const options = this._options;
|
|
const ignoreForCrawl = (filePath: string) =>
|
|
options.ignoreForCrawl(filePath) ||
|
|
path.basename(filePath).startsWith(this._options.healthCheckFilePrefix);
|
|
const crawl = options.useWatchman ? watchmanCrawl : nodeCrawl;
|
|
let crawler = crawl === watchmanCrawl ? 'watchman' : 'node';
|
|
|
|
options.abortSignal.throwIfAborted();
|
|
|
|
const crawlerOptions: CrawlerOptions = {
|
|
abortSignal: options.abortSignal,
|
|
computeSha1: options.computeSha1,
|
|
console: options.console,
|
|
includeSymlinks: options.enableSymlinks,
|
|
extensions: options.extensions,
|
|
forceNodeFilesystemAPI: options.forceNodeFilesystemAPI,
|
|
ignore: ignoreForCrawl,
|
|
onStatus: status => {
|
|
this.emit('status', status);
|
|
},
|
|
perfLogger: options.perfLogger,
|
|
previousState: options.previousState,
|
|
rootDir: options.rootDir,
|
|
roots: options.roots,
|
|
};
|
|
|
|
const retry = (error: Error): Promise<CrawlResult> => {
|
|
if (crawl === watchmanCrawl) {
|
|
crawler = 'node';
|
|
options.console.warn(
|
|
'metro-file-map: Watchman crawl failed. Retrying once with node ' +
|
|
'crawler.\n' +
|
|
" Usually this happens when watchman isn't running. Create an " +
|
|
"empty `.watchmanconfig` file in your project's root folder or " +
|
|
'initialize a git or hg repository in your project.\n' +
|
|
' ' +
|
|
error.toString(),
|
|
);
|
|
// $FlowFixMe[incompatible-type] Found when updating Promise type definition
|
|
return nodeCrawl(crawlerOptions).catch<CrawlResult>(e => {
|
|
throw new Error(
|
|
'Crawler retry failed:\n' +
|
|
` Original error: ${error.message}\n` +
|
|
` Retry error: ${e.message}\n`,
|
|
);
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
};
|
|
|
|
const logEnd = (delta: CrawlResult): CrawlResult => {
|
|
debug(
|
|
'Crawler "%s" returned %d added/modified, %d removed, %d clock(s).',
|
|
crawler,
|
|
delta.changedFiles.size,
|
|
delta.removedFiles.size,
|
|
delta.clocks?.size ?? 0,
|
|
);
|
|
this._options.perfLogger?.point('crawl_end');
|
|
return delta;
|
|
};
|
|
|
|
debug('Beginning crawl with "%s".', crawler);
|
|
try {
|
|
// $FlowFixMe[incompatible-type] Found when updating Promise type definition
|
|
return crawl(crawlerOptions).catch<CrawlResult>(retry).then(logEnd);
|
|
} catch (error) {
|
|
return retry(error).then(logEnd);
|
|
}
|
|
}
|
|
|
|
async watch(onChange: (change: WatcherBackendChangeEvent) => void) {
|
|
const {extensions, ignorePatternForWatch, useWatchman} = this._options;
|
|
|
|
// WatchmanWatcher > NativeWatcher > FallbackWatcher
|
|
const WatcherImpl = useWatchman
|
|
? WatchmanWatcher
|
|
: NativeWatcher.isSupported()
|
|
? NativeWatcher
|
|
: FallbackWatcher;
|
|
|
|
let watcher = 'fallback';
|
|
if (WatcherImpl === WatchmanWatcher) {
|
|
watcher = 'watchman';
|
|
} else if (WatcherImpl === NativeWatcher) {
|
|
watcher = 'native';
|
|
}
|
|
debug(`Using watcher: ${watcher}`);
|
|
this._options.perfLogger?.annotate({string: {watcher}});
|
|
this._activeWatcher = watcher;
|
|
|
|
const createWatcherBackend = (root: Path): Promise<WatcherBackend> => {
|
|
const watcherOptions: WatcherBackendOptions = {
|
|
dot: true,
|
|
globs: [
|
|
// Ensure we always include package.json files, which are crucial for
|
|
/// module resolution.
|
|
'**/package.json',
|
|
// Ensure we always watch any health check files
|
|
'**/' + this._options.healthCheckFilePrefix + '*',
|
|
...extensions.map(extension => '**/*.' + extension),
|
|
],
|
|
ignored: ignorePatternForWatch,
|
|
watchmanDeferStates: this._options.watchmanDeferStates,
|
|
};
|
|
const watcher: WatcherBackend = new WatcherImpl(root, watcherOptions);
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
const rejectTimeout = setTimeout(
|
|
() => reject(new Error('Failed to start watch mode.')),
|
|
MAX_WAIT_TIME,
|
|
);
|
|
|
|
watcher.onFileEvent(change => {
|
|
const basename = path.basename(change.relativePath);
|
|
if (basename.startsWith(this._options.healthCheckFilePrefix)) {
|
|
if (change.event === TOUCH_EVENT) {
|
|
debug(
|
|
'Observed possible health check cookie: %s in %s',
|
|
change.relativePath,
|
|
root,
|
|
);
|
|
this._handleHealthCheckObservation(basename);
|
|
}
|
|
return;
|
|
}
|
|
onChange(change);
|
|
});
|
|
await watcher.startWatching();
|
|
clearTimeout(rejectTimeout);
|
|
resolve(watcher);
|
|
});
|
|
};
|
|
|
|
this._backends = await Promise.all(
|
|
this._options.roots.map(createWatcherBackend),
|
|
);
|
|
}
|
|
|
|
_handleHealthCheckObservation(basename: string) {
|
|
const resolveHealthCheck = this._pendingHealthChecks.get(basename);
|
|
if (!resolveHealthCheck) {
|
|
return;
|
|
}
|
|
resolveHealthCheck();
|
|
}
|
|
|
|
async close() {
|
|
await Promise.all(this._backends.map(watcher => watcher.stopWatching()));
|
|
this._activeWatcher = null;
|
|
}
|
|
|
|
async checkHealth(timeout: number): Promise<HealthCheckResult> {
|
|
const healthCheckId = this._nextHealthCheckId++;
|
|
if (healthCheckId === Number.MAX_SAFE_INTEGER) {
|
|
this._nextHealthCheckId = 0;
|
|
}
|
|
const watcher = this._activeWatcher;
|
|
const basename =
|
|
this._options.healthCheckFilePrefix +
|
|
'-' +
|
|
process.pid +
|
|
'-' +
|
|
this._instanceId +
|
|
'-' +
|
|
healthCheckId;
|
|
const healthCheckPath = path.join(this._options.rootDir, basename);
|
|
let result: ?HealthCheckResult;
|
|
const timeoutPromise = new Promise(resolve =>
|
|
setTimeout(resolve, timeout),
|
|
).then(() => {
|
|
if (!result) {
|
|
result = {
|
|
type: 'timeout',
|
|
pauseReason: this._backends[0]?.getPauseReason(),
|
|
timeout,
|
|
watcher,
|
|
};
|
|
}
|
|
});
|
|
const startTime = performance.now();
|
|
debug('Creating health check cookie: %s', healthCheckPath);
|
|
const creationPromise = fs.promises
|
|
.writeFile(healthCheckPath, String(startTime))
|
|
.catch(error => {
|
|
if (!result) {
|
|
result = {
|
|
type: 'error',
|
|
error,
|
|
timeout,
|
|
watcher,
|
|
};
|
|
}
|
|
});
|
|
const observationPromise = new Promise(resolve => {
|
|
this._pendingHealthChecks.set(basename, resolve);
|
|
}).then(() => {
|
|
if (!result) {
|
|
result = {
|
|
type: 'success',
|
|
timeElapsed: performance.now() - startTime,
|
|
timeout,
|
|
watcher,
|
|
};
|
|
}
|
|
});
|
|
await Promise.race([
|
|
timeoutPromise,
|
|
creationPromise.then(() => observationPromise),
|
|
]);
|
|
this._pendingHealthChecks.delete(basename);
|
|
// Chain a deletion to the creation promise (which may not have even settled yet!),
|
|
// don't await it, and swallow errors. This is just best-effort cleanup.
|
|
// $FlowFixMe[unused-promise]
|
|
creationPromise.then(() =>
|
|
fs.promises.unlink(healthCheckPath).catch(() => {}),
|
|
);
|
|
debug('Health check result: %o', result);
|
|
return nullthrows(result);
|
|
}
|
|
}
|