Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/metro-file-map/src/Watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import type {
WatcherBackend,
WatcherBackendChangeEvent,
} from './flow-types';
import type {WatcherOptions as WatcherBackendOptions} from './watchers/common';
import type {
WatcherOptions as WatcherBackendOptions,
WatchProbeResult,
} from './watchers/common';

import nodeCrawl from './crawlers/node';
import watchmanCrawl from './crawlers/watchman';
Expand Down Expand Up @@ -220,6 +223,7 @@ export class Watcher extends EventEmitter {
this.#activeWatcher = watcher;

const createWatcherBackend = (root: Path): Promise<WatcherBackend> => {
let probeResult: ?WatchProbeResult = null;
const watcherOptions: WatcherBackendOptions = {
dot: true,
globs: [
Expand All @@ -231,6 +235,11 @@ export class Watcher extends EventEmitter {
...extensions.map(extension => '**/*.' + extension),
],
ignored: ignorePatternForWatch,
probe: async timeoutMs => {
const {type} = await this.#checkHealth(root, timeoutMs);
probeResult = type === 'success' ? 'observed' : type;
return probeResult;
},
watchmanDeferStates: this.#options.watchmanDeferStates,
};
const watcher: WatcherBackend = new WatcherImpl(root, watcherOptions);
Expand Down Expand Up @@ -267,6 +276,13 @@ export class Watcher extends EventEmitter {
});
await watcher.startWatching();
clearTimeout(rejectTimeout);
if (probeResult != null && probeResult !== 'observed') {
this.#options.console.warn(
`metro-file-map: Could not confirm that ${root} is being watched ` +
`(probe result: ${probeResult}). Changes made during startup ` +
'may be missed.',
);
}
resolve(watcher);
});
};
Expand All @@ -290,6 +306,10 @@ export class Watcher extends EventEmitter {
}

async checkHealth(timeout: number): Promise<HealthCheckResult> {
return this.#checkHealth(this.#options.rootDir, timeout);
}

async #checkHealth(dir: string, timeout: number): Promise<HealthCheckResult> {
const healthCheckId = this.#nextHealthCheckId++;
if (healthCheckId === Number.MAX_SAFE_INTEGER) {
this.#nextHealthCheckId = 0;
Expand All @@ -303,7 +323,7 @@ export class Watcher extends EventEmitter {
this.#instanceId +
'-' +
healthCheckId;
const healthCheckPath = path.join(this.#options.rootDir, basename);
const healthCheckPath = path.join(dir, basename);
let result: ?HealthCheckResult;
const timeoutPromise = new Promise(resolve =>
setTimeout(resolve, timeout),
Expand Down
22 changes: 22 additions & 0 deletions packages/metro-file-map/src/watchers/NativeWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import type {WatcherBackendChangeEvent} from '../flow-types';
import type {WatchProbe} from './common';
import type {FSWatcher} from 'node:fs';

import {AbstractWatcher} from './AbstractWatcher';
Expand All @@ -24,6 +25,10 @@ const TOUCH_EVENT = 'touch';
const DELETE_EVENT = 'delete';
const RECRAWL_EVENT = 'recrawl';

// How long to wait for each probe, and for the watch to become live overall.
const PROBE_TIMEOUT_MS = 200;
const LIVE_TIMEOUT_MS = 10000;

/**
* NativeWatcher uses Node's native fs.watch API with recursive: true.
*
Expand All @@ -46,6 +51,7 @@ const RECRAWL_EVENT = 'recrawl';
*/
export default class NativeWatcher extends AbstractWatcher {
#fsWatcher: ?FSWatcher;
readonly #probe: ?WatchProbe;

/**
* Promise chain to emit events in the order they were received.
Expand All @@ -62,13 +68,15 @@ export default class NativeWatcher extends AbstractWatcher {
ignored: ?RegExp,
globs: ReadonlyArray<string>,
dot: boolean,
probe?: ?WatchProbe,
...
}>,
) {
if (!NativeWatcher.isSupported()) {
throw new Error('This watcher can only be used on macOS');
}
super(dir, opts);
this.#probe = opts.probe;
}

async startWatching(): Promise<void> {
Expand Down Expand Up @@ -108,6 +116,20 @@ export default class NativeWatcher extends AbstractWatcher {
);

debug('Watching %s', this.root);

// fs.watch can return before any events are delivered: libuv starts the
// FSEvents stream on another thread, and the stream reports only changes
// made after it starts. Probe until a change is reported, so that changes
// made as soon as this resolves are not missed.
const probe = this.#probe;
if (probe != null) {
const deadline = Date.now() + LIVE_TIMEOUT_MS;
let result;
do {
result = await probe(PROBE_TIMEOUT_MS);
} while (result === 'timeout' && Date.now() < deadline);
debug('Probed watch of %s: %s', this.root, result);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import type {WatcherBackendChangeEvent} from '../../flow-types';
import type {WatchProbeResult} from '../common';

import NativeWatcher from '../NativeWatcher';
import fs from 'node:fs';
Expand Down Expand Up @@ -161,4 +162,45 @@ describe('NativeWatcher', () => {
await flush();
expect(order).toEqual(['touch:first.js', 'error:EACCES', 'touch:third.js']);
});

test.each([
[['observed'], 1],
[['timeout', 'timeout', 'observed'], 3],
[['timeout', 'error', 'observed'], 2],
])(
'startWatching probes until the watch is live: %j',
async (results, expectedCalls) => {
const probe = jest.fn<[number], Promise<WatchProbeResult>>();
for (const result of results) {
probe.mockResolvedValueOnce(result);
}
const probed = new NativeWatcher(ROOT, {
dot: true,
globs: [],
ignored: null,
probe,
});
await probed.startWatching();
expect(probe).toHaveBeenCalledTimes(expectedCalls);
await probed.stopWatching();
},
);

test('startWatching stops probing after an overall timeout', async () => {
let now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
const probe = jest.fn<[number], Promise<WatchProbeResult>>(async () => {
now += 4000;
return 'timeout';
});
const probed = new NativeWatcher(ROOT, {
dot: true,
globs: [],
ignored: null,
probe,
});
await probed.startWatching();
expect(probe).toHaveBeenCalledTimes(3);
await probed.stopWatching();
});
});
28 changes: 26 additions & 2 deletions packages/metro-file-map/src/watchers/__tests__/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import type {ChangeEventMetadata} from '../../flow-types';
import type {WatcherOptions} from '../common';
import type {WatcherOptions, WatchProbe} from '../common';

import FallbackWatcher from '../FallbackWatcher';
import NativeWatcher from '../NativeWatcher';
Expand Down Expand Up @@ -105,7 +105,31 @@ export const startWatching = async (
}>) => {
const Watcher = WATCHERS[watcherName];
invariant(Watcher != null, `Watcher ${watcherName} is not supported`);
const watcherInstance = new Watcher(watchRoot, opts);
// Probe with cookie files (matched by the `cookie-*` glob), as Metro does
// with health check files. Watchers emit in order, so once the latest is
// observed, none of the earlier ones remain in flight to leak into a test.
let probeCount = 0;
const probe: WatchProbe = async timeoutMs => {
const cookie = `cookie-probe-${++probeCount}`;
let unsubscribe: () => void = () => {};
const observed = new Promise<'observed'>(resolve => {
unsubscribe = watcherInstance.onFileEvent(change => {
if (change.relativePath === cookie) {
resolve('observed');
}
});
});
await writeFile(join(watchRoot, cookie), '');
const result = await Promise.race([
observed,
new Promise<'timeout'>(resolve =>
setTimeout(() => resolve('timeout'), timeoutMs),
),
]);
unsubscribe();
return result;
};
const watcherInstance = new Watcher(watchRoot, {...opts, probe});

await watcherInstance.startWatching();

Expand Down
14 changes: 14 additions & 0 deletions packages/metro-file-map/src/watchers/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,24 @@ export const TOUCH_EVENT = 'touch';
export const RECRAWL_EVENT = 'recrawl';
export const ALL_EVENT = 'all';

/**
* Writes a file under the watched root and resolves with whether the backend
* reported it within `timeoutMs`, or 'error' if it could not be written.
*/
export type WatchProbe = (timeoutMs: number) => Promise<WatchProbeResult>;
export type WatchProbeResult = 'observed' | 'timeout' | 'error';

export type WatcherOptions = Readonly<{
globs: ReadonlyArray<string>,
dot: boolean,
ignored: ?RegExp,
/**
* Used by backends whose watch may not be live as soon as it is set up, to
* wait until it is before startWatching resolves. NativeWatcher backends in
* one process share an FSEvents stream, which restarts as each is added, so
* a watch is only guaranteed live once every backend has started.
*/
probe?: ?WatchProbe,
watchmanDeferStates: ReadonlyArray<string>,
watchman?: unknown,
watchmanPath?: string,
Expand Down
Loading