Skip to content

Commit

Permalink
Add optional periodic watcher health check
Browse files Browse the repository at this point in the history
Summary:
Adds the ability to periodically check the health of the filesystem watcher by writing a temporary file to the project and waiting for it to be observed.

This is off by default and does not generally need to be turned on by end users. It's primarily useful for debugging/monitoring the reliability of the underlying watcher or filesystem.

Implementation approach:
* The `Watcher` class (new in D39891465 (dc02eac)) now has a `checkHealth` method that returns a machine-readable description of the result of a health check (success, timeout or error, plus some metadata).
* The `Watcher` class hides health check files from `HasteMap`; it excludes them from crawl results and doesn't forward notifications about them.
* If health checks are enabled, `HasteMap` performs one as soon as a watch is established, and sets an interval to run checks periodically.
* `HasteMap` emits `healthCheck` events with health check results.
* `DependencyGraph` converts `healthCheck` events to `ReportableEvent`s and logs them via the current `reporter`.
* `TerminalReporter` prints a human-readable version of the health check result when there is a relevant change.

Changelog:
* **[Feature]**: Add configurable watcher health check that is off by default.

Reviewed By: robhogan

Differential Revision: D40352039

fbshipit-source-id: 75ed4bd845b7919cfee4f64223a24444c98d1735
  • Loading branch information
motiz88 authored and facebook-github-bot committed Oct 14, 2022
1 parent 0006a7e commit 7adf468
Show file tree
Hide file tree
Showing 15 changed files with 329 additions and 6 deletions.
38 changes: 38 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,44 @@ Therefore, the two behaviour differences from `resolver.sourceExts` when importi

Defaults to `['cjs', 'mjs']`.

#### `healthCheck.enabled`

Type: `boolean`

Whether to periodically check the health of the filesystem watcher by writing a temporary file to the project and waiting for it to be observed.

The default value is `false`.

#### `healthCheck.filePrefix`

Type: `string`

If watcher health checks are enabled, this property controls the name of the temporary file that will be written into the project filesystem.

The default value is `'.metro-health-check'`.

:::note

There's no need to commit health check files to source control. If you choose to enable health checks in your project, make sure you add `.metro-health-check*` to your `.gitignore` file to avoid generating unnecessary changes.

:::

#### `healthCheck.interval`

Type: `number`

If watcher health checks are enabled, this property controls how often they occur (in milliseconds).

The default value is 30000.

#### `healthCheck.timeout`

Type: `number`

If watcher health checks are enabled, this property controls the time (in milliseconds) Metro will wait for a file change to be observed before considering the check to have failed.

The default value is 5000.

#### `watchman.deferStates`

Type: `Array<string>`
Expand Down
25 changes: 25 additions & 0 deletions flow-typed/perf_hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* 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.
*
* @flow strict
* @format
*/

// An incomplete definition for Node's builtin `perf_hooks` module.

declare module 'perf_hooks' {
declare export var performance: {
clearMarks(name?: string): void,
mark(name?: string): void,
measure(name: string, startMark?: string, endMark?: string): void,
nodeTiming: mixed /* FIXME */,
now(): number,
timeOrigin: number,
timerify<TArgs: Iterable<mixed>, TReturn>(
f: (...TArgs) => TReturn,
): (...TArgs) => TReturn,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ Object {
"cjs",
"mjs",
],
"healthCheck": Object {
"enabled": false,
"filePrefix": ".metro-health-check",
"interval": 30000,
"timeout": 5000,
},
"watchman": Object {
"deferStates": Array [
"hg.update",
Expand Down Expand Up @@ -313,6 +319,12 @@ Object {
"cjs",
"mjs",
],
"healthCheck": Object {
"enabled": false,
"filePrefix": ".metro-health-check",
"interval": 30000,
"timeout": 5000,
},
"watchman": Object {
"deferStates": Array [
"hg.update",
Expand Down Expand Up @@ -474,6 +486,12 @@ Object {
"cjs",
"mjs",
],
"healthCheck": Object {
"enabled": false,
"filePrefix": ".metro-health-check",
"interval": 30000,
"timeout": 5000,
},
"watchman": Object {
"deferStates": Array [
"hg.update",
Expand Down Expand Up @@ -635,6 +653,12 @@ Object {
"cjs",
"mjs",
],
"healthCheck": Object {
"enabled": false,
"filePrefix": ".metro-health-check",
"interval": 30000,
"timeout": 5000,
},
"watchman": Object {
"deferStates": Array [
"hg.update",
Expand Down
11 changes: 10 additions & 1 deletion packages/metro-config/src/configTypes.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ type WatcherConfigT = {
watchman: {
deferStates: $ReadOnlyArray<string>,
},
healthCheck: {
enabled: boolean,
interval: number,
timeout: number,
filePrefix: string,
},
};

export type InputConfigT = $Shape<{
Expand All @@ -188,7 +194,10 @@ export type InputConfigT = $Shape<{
serializer: $Shape<SerializerConfigT>,
symbolicator: $Shape<SymbolicatorConfigT>,
transformer: $Shape<TransformerConfigT>,
watcher: $Shape<WatcherConfigT>,
watcher: $Shape<{
...WatcherConfigT,
healthCheck?: $Shape<WatcherConfigT['healthCheck']>,
}>,
}>,
}>;

Expand Down
6 changes: 6 additions & 0 deletions packages/metro-config/src/defaults/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
watchman: {
deferStates: ['hg.update'],
},
healthCheck: {
enabled: false,
filePrefix: '.metro-health-check',
interval: 30000,
timeout: 5000,
},
},
cacheStores: [
new FileStore({
Expand Down
5 changes: 5 additions & 0 deletions packages/metro-config/src/loadConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ function mergeConfig<T: InputConfigT>(
...totalConfig.watcher?.watchman,
...nextConfig.watcher?.watchman,
},
healthCheck: {
...totalConfig.watcher?.healthCheck,
// $FlowFixMe: Spreading shapes creates an explosion of union types
...nextConfig.watcher?.healthCheck,
},
},
}),
defaultConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/metro-file-map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"jest-util": "^27.2.0",
"jest-worker": "^27.2.0",
"micromatch": "^4.0.4",
"nullthrows": "^1.1.1",
"walker": "^1.0.7"
},
"devDependencies": {
Expand Down
121 changes: 119 additions & 2 deletions packages/metro-file-map/src/Watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import WatchmanWatcher from './watchers/WatchmanWatcher';
import FSEventsWatcher from './watchers/FSEventsWatcher';
// $FlowFixMe[untyped-import] - it's a fork: https://github.com/facebook/jest/pull/10919
import NodeWatcher from './watchers/NodeWatcher';
import * as path from 'path';
import * as fs from 'fs';
import {ADD_EVENT, CHANGE_EVENT} from './watchers/common';
import {performance} from 'perf_hooks';
import nullthrows from 'nullthrows';

const debug = require('debug')('Metro:Watcher');

Expand All @@ -37,6 +42,7 @@ type WatcherOptions = {
enableSymlinks: boolean,
extensions: $ReadOnlyArray<string>,
forceNodeFilesystemAPI: boolean,
healthCheckFilePrefix: string,
ignore: string => boolean,
ignorePattern: RegExp,
initialData: InternalData,
Expand All @@ -52,12 +58,25 @@ interface WatcherBackend {
close(): Promise<void>;
}

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};

export class Watcher {
_options: WatcherOptions;
_backends: $ReadOnlyArray<WatcherBackend> = [];
_instanceId: number;
_nextHealthCheckId: number = 0;
_pendingHealthChecks: Map</* basename */ string, /* resolve */ () => void> =
new Map();
_activeWatcher: ?string;

constructor(options: WatcherOptions) {
this._options = options;
this._instanceId = nextInstanceId++;
}

async crawl(): Promise<?(
Expand All @@ -71,7 +90,9 @@ export class Watcher {
this._options.perfLogger?.point('crawl_start');

const options = this._options;
const ignore = (filePath: string) => options.ignore(filePath);
const ignore = (filePath: string) =>
options.ignore(filePath) ||
path.basename(filePath).startsWith(this._options.healthCheckFilePrefix);
const crawl = options.useWatchman ? watchmanCrawl : nodeCrawl;
const crawlerOptions: CrawlerOptions = {
abortSignal: options.abortSignal,
Expand Down Expand Up @@ -146,6 +167,7 @@ export class Watcher {
}
debug(`Using watcher: ${watcher}`);
this._options.perfLogger?.annotate({string: {watcher}});
this._activeWatcher = watcher;

const createWatcherBackend = (root: Path): Promise<WatcherBackend> => {
const watcherOptions: WatcherBackendOptions = {
Expand All @@ -154,6 +176,8 @@ export class Watcher {
// 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: ignorePattern,
Expand All @@ -169,7 +193,24 @@ export class Watcher {

watcher.once('ready', () => {
clearTimeout(rejectTimeout);
watcher.on('all', onChange);
watcher.on(
'all',
(type: string, filePath: string, root: string, stat?: Stats) => {
const basename = path.basename(filePath);
if (basename.startsWith(this._options.healthCheckFilePrefix)) {
if (type === ADD_EVENT || type === CHANGE_EVENT) {
debug(
'Observed possible health check cookie: %s in %s',
filePath,
root,
);
this._handleHealthCheckObservation(basename);
}
return;
}
onChange(type, filePath, root, stat);
},
);
resolve(watcher);
});
});
Expand All @@ -180,7 +221,83 @@ export class Watcher {
);
}

_handleHealthCheckObservation(basename: string) {
const resolveHealthCheck = this._pendingHealthChecks.get(basename);
if (!resolveHealthCheck) {
return;
}
resolveHealthCheck();
}

async close() {
await Promise.all(this._backends.map(watcher => watcher.close()));
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;
const timeoutPromise = new Promise(resolve =>
setTimeout(resolve, timeout),
).then(() => {
if (!result) {
result = {
type: 'timeout',
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.
creationPromise.then(() =>
fs.promises.unlink(healthCheckPath).catch(() => {}),
);
debug('Health check result: %o', result);
return nullthrows(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
Expand All @@ -21,18 +22,22 @@ const commonOptions = {
retainAllFiles: true,
rootDir,
roots: [rootDir],
healthCheck: {
enabled: false,
interval: 10000,
timeout: 1000,
filePrefix: '.metro-file-map-health-check',
},
};

test('watchman crawler and node crawler both include dotfiles', async () => {
const hasteMapWithWatchman = new HasteMap({
...commonOptions,
name: 'withWatchman',
useWatchman: true,
});

const hasteMapWithNode = new HasteMap({
...commonOptions,
name: 'withNode',
useWatchman: false,
});

Expand Down
Loading

0 comments on commit 7adf468

Please sign in to comment.