Skip to content

Commit 7adf468

Browse files
motiz88facebook-github-bot
authored andcommitted
Add optional periodic watcher health check
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
1 parent 0006a7e commit 7adf468

File tree

15 files changed

+329
-6
lines changed

15 files changed

+329
-6
lines changed

docs/Configuration.md

+38
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,44 @@ Therefore, the two behaviour differences from `resolver.sourceExts` when importi
418418

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

421+
#### `healthCheck.enabled`
422+
423+
Type: `boolean`
424+
425+
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.
426+
427+
The default value is `false`.
428+
429+
#### `healthCheck.filePrefix`
430+
431+
Type: `string`
432+
433+
If watcher health checks are enabled, this property controls the name of the temporary file that will be written into the project filesystem.
434+
435+
The default value is `'.metro-health-check'`.
436+
437+
:::note
438+
439+
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.
440+
441+
:::
442+
443+
#### `healthCheck.interval`
444+
445+
Type: `number`
446+
447+
If watcher health checks are enabled, this property controls how often they occur (in milliseconds).
448+
449+
The default value is 30000.
450+
451+
#### `healthCheck.timeout`
452+
453+
Type: `number`
454+
455+
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.
456+
457+
The default value is 5000.
458+
421459
#### `watchman.deferStates`
422460

423461
Type: `Array<string>`

flow-typed/perf_hooks.js

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
*/
10+
11+
// An incomplete definition for Node's builtin `perf_hooks` module.
12+
13+
declare module 'perf_hooks' {
14+
declare export var performance: {
15+
clearMarks(name?: string): void,
16+
mark(name?: string): void,
17+
measure(name: string, startMark?: string, endMark?: string): void,
18+
nodeTiming: mixed /* FIXME */,
19+
now(): number,
20+
timeOrigin: number,
21+
timerify<TArgs: Iterable<mixed>, TReturn>(
22+
f: (...TArgs) => TReturn,
23+
): (...TArgs) => TReturn,
24+
};
25+
}

packages/metro-config/src/__tests__/__snapshots__/loadConfig-test.js.snap

+24
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ Object {
152152
"cjs",
153153
"mjs",
154154
],
155+
"healthCheck": Object {
156+
"enabled": false,
157+
"filePrefix": ".metro-health-check",
158+
"interval": 30000,
159+
"timeout": 5000,
160+
},
155161
"watchman": Object {
156162
"deferStates": Array [
157163
"hg.update",
@@ -313,6 +319,12 @@ Object {
313319
"cjs",
314320
"mjs",
315321
],
322+
"healthCheck": Object {
323+
"enabled": false,
324+
"filePrefix": ".metro-health-check",
325+
"interval": 30000,
326+
"timeout": 5000,
327+
},
316328
"watchman": Object {
317329
"deferStates": Array [
318330
"hg.update",
@@ -474,6 +486,12 @@ Object {
474486
"cjs",
475487
"mjs",
476488
],
489+
"healthCheck": Object {
490+
"enabled": false,
491+
"filePrefix": ".metro-health-check",
492+
"interval": 30000,
493+
"timeout": 5000,
494+
},
477495
"watchman": Object {
478496
"deferStates": Array [
479497
"hg.update",
@@ -635,6 +653,12 @@ Object {
635653
"cjs",
636654
"mjs",
637655
],
656+
"healthCheck": Object {
657+
"enabled": false,
658+
"filePrefix": ".metro-health-check",
659+
"interval": 30000,
660+
"timeout": 5000,
661+
},
638662
"watchman": Object {
639663
"deferStates": Array [
640664
"hg.update",

packages/metro-config/src/configTypes.flow.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ type WatcherConfigT = {
175175
watchman: {
176176
deferStates: $ReadOnlyArray<string>,
177177
},
178+
healthCheck: {
179+
enabled: boolean,
180+
interval: number,
181+
timeout: number,
182+
filePrefix: string,
183+
},
178184
};
179185

180186
export type InputConfigT = $Shape<{
@@ -188,7 +194,10 @@ export type InputConfigT = $Shape<{
188194
serializer: $Shape<SerializerConfigT>,
189195
symbolicator: $Shape<SymbolicatorConfigT>,
190196
transformer: $Shape<TransformerConfigT>,
191-
watcher: $Shape<WatcherConfigT>,
197+
watcher: $Shape<{
198+
...WatcherConfigT,
199+
healthCheck?: $Shape<WatcherConfigT['healthCheck']>,
200+
}>,
192201
}>,
193202
}>;
194203

packages/metro-config/src/defaults/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
135135
watchman: {
136136
deferStates: ['hg.update'],
137137
},
138+
healthCheck: {
139+
enabled: false,
140+
filePrefix: '.metro-health-check',
141+
interval: 30000,
142+
timeout: 5000,
143+
},
138144
},
139145
cacheStores: [
140146
new FileStore({

packages/metro-config/src/loadConfig.js

+5
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ function mergeConfig<T: InputConfigT>(
170170
...totalConfig.watcher?.watchman,
171171
...nextConfig.watcher?.watchman,
172172
},
173+
healthCheck: {
174+
...totalConfig.watcher?.healthCheck,
175+
// $FlowFixMe: Spreading shapes creates an explosion of union types
176+
...nextConfig.watcher?.healthCheck,
177+
},
173178
},
174179
}),
175180
defaultConfig,

packages/metro-file-map/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"jest-util": "^27.2.0",
2525
"jest-worker": "^27.2.0",
2626
"micromatch": "^4.0.4",
27+
"nullthrows": "^1.1.1",
2728
"walker": "^1.0.7"
2829
},
2930
"devDependencies": {

packages/metro-file-map/src/Watcher.js

+119-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import WatchmanWatcher from './watchers/WatchmanWatcher';
2525
import FSEventsWatcher from './watchers/FSEventsWatcher';
2626
// $FlowFixMe[untyped-import] - it's a fork: https://github.com/facebook/jest/pull/10919
2727
import NodeWatcher from './watchers/NodeWatcher';
28+
import * as path from 'path';
29+
import * as fs from 'fs';
30+
import {ADD_EVENT, CHANGE_EVENT} from './watchers/common';
31+
import {performance} from 'perf_hooks';
32+
import nullthrows from 'nullthrows';
2833

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

@@ -37,6 +42,7 @@ type WatcherOptions = {
3742
enableSymlinks: boolean,
3843
extensions: $ReadOnlyArray<string>,
3944
forceNodeFilesystemAPI: boolean,
45+
healthCheckFilePrefix: string,
4046
ignore: string => boolean,
4147
ignorePattern: RegExp,
4248
initialData: InternalData,
@@ -52,12 +58,25 @@ interface WatcherBackend {
5258
close(): Promise<void>;
5359
}
5460

61+
let nextInstanceId = 0;
62+
63+
export type HealthCheckResult =
64+
| {type: 'error', timeout: number, error: Error, watcher: ?string}
65+
| {type: 'success', timeout: number, timeElapsed: number, watcher: ?string}
66+
| {type: 'timeout', timeout: number, watcher: ?string};
67+
5568
export class Watcher {
5669
_options: WatcherOptions;
5770
_backends: $ReadOnlyArray<WatcherBackend> = [];
71+
_instanceId: number;
72+
_nextHealthCheckId: number = 0;
73+
_pendingHealthChecks: Map</* basename */ string, /* resolve */ () => void> =
74+
new Map();
75+
_activeWatcher: ?string;
5876

5977
constructor(options: WatcherOptions) {
6078
this._options = options;
79+
this._instanceId = nextInstanceId++;
6180
}
6281

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

7392
const options = this._options;
74-
const ignore = (filePath: string) => options.ignore(filePath);
93+
const ignore = (filePath: string) =>
94+
options.ignore(filePath) ||
95+
path.basename(filePath).startsWith(this._options.healthCheckFilePrefix);
7596
const crawl = options.useWatchman ? watchmanCrawl : nodeCrawl;
7697
const crawlerOptions: CrawlerOptions = {
7798
abortSignal: options.abortSignal,
@@ -146,6 +167,7 @@ export class Watcher {
146167
}
147168
debug(`Using watcher: ${watcher}`);
148169
this._options.perfLogger?.annotate({string: {watcher}});
170+
this._activeWatcher = watcher;
149171

150172
const createWatcherBackend = (root: Path): Promise<WatcherBackend> => {
151173
const watcherOptions: WatcherBackendOptions = {
@@ -154,6 +176,8 @@ export class Watcher {
154176
// Ensure we always include package.json files, which are crucial for
155177
/// module resolution.
156178
'**/package.json',
179+
// Ensure we always watch any health check files
180+
'**/' + this._options.healthCheckFilePrefix + '*',
157181
...extensions.map(extension => '**/*.' + extension),
158182
],
159183
ignored: ignorePattern,
@@ -169,7 +193,24 @@ export class Watcher {
169193

170194
watcher.once('ready', () => {
171195
clearTimeout(rejectTimeout);
172-
watcher.on('all', onChange);
196+
watcher.on(
197+
'all',
198+
(type: string, filePath: string, root: string, stat?: Stats) => {
199+
const basename = path.basename(filePath);
200+
if (basename.startsWith(this._options.healthCheckFilePrefix)) {
201+
if (type === ADD_EVENT || type === CHANGE_EVENT) {
202+
debug(
203+
'Observed possible health check cookie: %s in %s',
204+
filePath,
205+
root,
206+
);
207+
this._handleHealthCheckObservation(basename);
208+
}
209+
return;
210+
}
211+
onChange(type, filePath, root, stat);
212+
},
213+
);
173214
resolve(watcher);
174215
});
175216
});
@@ -180,7 +221,83 @@ export class Watcher {
180221
);
181222
}
182223

224+
_handleHealthCheckObservation(basename: string) {
225+
const resolveHealthCheck = this._pendingHealthChecks.get(basename);
226+
if (!resolveHealthCheck) {
227+
return;
228+
}
229+
resolveHealthCheck();
230+
}
231+
183232
async close() {
184233
await Promise.all(this._backends.map(watcher => watcher.close()));
234+
this._activeWatcher = null;
235+
}
236+
237+
async checkHealth(timeout: number): Promise<HealthCheckResult> {
238+
const healthCheckId = this._nextHealthCheckId++;
239+
if (healthCheckId === Number.MAX_SAFE_INTEGER) {
240+
this._nextHealthCheckId = 0;
241+
}
242+
const watcher = this._activeWatcher;
243+
const basename =
244+
this._options.healthCheckFilePrefix +
245+
'-' +
246+
process.pid +
247+
'-' +
248+
this._instanceId +
249+
'-' +
250+
healthCheckId;
251+
const healthCheckPath = path.join(this._options.rootDir, basename);
252+
let result;
253+
const timeoutPromise = new Promise(resolve =>
254+
setTimeout(resolve, timeout),
255+
).then(() => {
256+
if (!result) {
257+
result = {
258+
type: 'timeout',
259+
timeout,
260+
watcher,
261+
};
262+
}
263+
});
264+
const startTime = performance.now();
265+
debug('Creating health check cookie: %s', healthCheckPath);
266+
const creationPromise = fs.promises
267+
.writeFile(healthCheckPath, String(startTime))
268+
.catch(error => {
269+
if (!result) {
270+
result = {
271+
type: 'error',
272+
error,
273+
timeout,
274+
watcher,
275+
};
276+
}
277+
});
278+
const observationPromise = new Promise(resolve => {
279+
this._pendingHealthChecks.set(basename, resolve);
280+
}).then(() => {
281+
if (!result) {
282+
result = {
283+
type: 'success',
284+
timeElapsed: performance.now() - startTime,
285+
timeout,
286+
watcher,
287+
};
288+
}
289+
});
290+
await Promise.race([
291+
timeoutPromise,
292+
creationPromise.then(() => observationPromise),
293+
]);
294+
this._pendingHealthChecks.delete(basename);
295+
// Chain a deletion to the creation promise (which may not have even settled yet!),
296+
// don't await it, and swallow errors. This is just best-effort cleanup.
297+
creationPromise.then(() =>
298+
fs.promises.unlink(healthCheckPath).catch(() => {}),
299+
);
300+
debug('Health check result: %o', result);
301+
return nullthrows(result);
185302
}
186303
}

packages/metro-file-map/src/__tests__/includes_dotfiles-test.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7+
* @flow strict-local
78
* @format
89
* @oncall react_native
910
*/
@@ -21,18 +22,22 @@ const commonOptions = {
2122
retainAllFiles: true,
2223
rootDir,
2324
roots: [rootDir],
25+
healthCheck: {
26+
enabled: false,
27+
interval: 10000,
28+
timeout: 1000,
29+
filePrefix: '.metro-file-map-health-check',
30+
},
2431
};
2532

2633
test('watchman crawler and node crawler both include dotfiles', async () => {
2734
const hasteMapWithWatchman = new HasteMap({
2835
...commonOptions,
29-
name: 'withWatchman',
3036
useWatchman: true,
3137
});
3238

3339
const hasteMapWithNode = new HasteMap({
3440
...commonOptions,
35-
name: 'withNode',
3641
useWatchman: false,
3742
});
3843

0 commit comments

Comments
 (0)