diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index 6d35e18b9a..acbc524272 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,6 +8,7 @@ * @format */ +import type {WatcherBackendChangeEvent} from '../flow-types'; import type {FSWatcher} from 'node:fs'; import {AbstractWatcher} from './AbstractWatcher'; @@ -46,6 +47,11 @@ const RECRAWL_EVENT = 'recrawl'; export default class NativeWatcher extends AbstractWatcher { #fsWatcher: ?FSWatcher; + /** + * Promise chain to emit events in the order they were received. + */ + #emitQueue: Promise = Promise.resolve(); + static isSupported(): boolean { return platform() === 'darwin'; } @@ -59,7 +65,7 @@ export default class NativeWatcher extends AbstractWatcher { ... }>, ) { - if (!NativeWatcher.isSupported) { + if (!NativeWatcher.isSupported()) { throw new Error('This watcher can only be used on macOS'); } super(dir, opts); @@ -76,7 +82,26 @@ export default class NativeWatcher extends AbstractWatcher { recursive: true, }, (event, relativePath) => { - this._handleEvent(event, relativePath).catch(error => { + // Start handling immediately so that stats are gathered concurrently + // and as close as possible to the event, but emit in arrival order. + const settled = this.#handleEvent(event, relativePath).then( + change => ({change, error: null}), + (error: Error) => ({change: null, error}), + ); + const emitted = this.#emitQueue.then(() => + settled.then(({change, error}) => { + if (error != null) { + throw error; + } + if (change != null) { + this.emitFileEvent(change); + } + }), + ); + // Report failures outside the queue, so that a throwing emitError + // (e.g. with no error listener) can't suppress later events. + this.#emitQueue = emitted.catch(() => {}); + emitted.catch(error => { this.emitError(error); }); }, @@ -95,7 +120,14 @@ export default class NativeWatcher extends AbstractWatcher { } } - async _handleEvent(event: string, relativePath: string) { + /** + * Resolve a raw `fs.watch` event into the event to emit for it, or `null` if + * it should be dropped. + */ + async #handleEvent( + event: string, + relativePath: string, + ): Promise> { const absolutePath = path.resolve(this.root, relativePath); if (this.doIgnore(relativePath)) { debug( @@ -104,7 +136,7 @@ export default class NativeWatcher extends AbstractWatcher { relativePath, this.root, ); - return; + return null; } debug( 'Handling event "%s" on %s (root: %s)', @@ -119,11 +151,11 @@ export default class NativeWatcher extends AbstractWatcher { // Ignore files of an unrecognized type if (!type) { - return; + return null; } if (!includedByGlob(type, this.globs, this.dot, relativePath)) { - return; + return null; } // For directory "rename" events, notify that we need a recrawl since we @@ -136,14 +168,13 @@ export default class NativeWatcher extends AbstractWatcher { 'Directory rename detected on %s, requesting recrawl', relativePath, ); - this.emitFileEvent({ + return { event: RECRAWL_EVENT, relativePath, - }); - return; + }; } - this.emitFileEvent({ + return { event: TOUCH_EVENT, relativePath, metadata: { @@ -151,14 +182,13 @@ export default class NativeWatcher extends AbstractWatcher { modifiedTime: stat.mtime.getTime(), size: stat.size, }, - }); + }; } catch (error) { if (error?.code !== 'ENOENT') { - this.emitError(error); - return; + throw error; } - this.emitFileEvent({event: DELETE_EVENT, relativePath}); + return {event: DELETE_EVENT, relativePath}; } } } diff --git a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js new file mode 100644 index 0000000000..f2ef843d12 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js @@ -0,0 +1,164 @@ +/** + * 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-local + * @format + * @oncall react_native + */ + +import type {WatcherBackendChangeEvent} from '../../flow-types'; + +import NativeWatcher from '../NativeWatcher'; +import fs from 'node:fs'; +import os from 'node:os'; +import {join, resolve} from 'node:path'; + +jest.useRealTimers(); + +// Absolute on every platform, with a drive letter on Windows, as the watcher +// resolves its root. +const ROOT = resolve('/', 'project'); + +type Deferred = { + promise: Promise, + resolve: T => void, + reject: Error => void, +}; + +function deferred(): Deferred { + let resolve: T => void = () => {}; + let reject: Error => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +function fileStat(mtimeMs: number): fs.Stats { + // $FlowFixMe[incompatible-type] - only the fields NativeWatcher reads + return { + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + mtime: new Date(mtimeMs), + size: 42, + }; +} + +function enoent(): Error { + const error = new Error('ENOENT: no such file or directory'); + // $FlowFixMe[prop-missing] - Node system errors carry a code + error.code = 'ENOENT'; + return error; +} + +// Run every pending promise continuation, including those queued by others. +const flush = () => new Promise(resolve => setImmediate(resolve)); + +describe('NativeWatcher', () => { + let watcher: NativeWatcher; + let emitFsEvent: (event: string, relativePath: string) => void; + let pendingStats: Map>; + let events: Array; + + beforeEach(async () => { + jest.spyOn(os, 'platform').mockReturnValue('darwin'); + jest.spyOn(fs, 'watch').mockImplementation((_root, _opts, listener) => { + emitFsEvent = listener; + return {close: () => {}}; + }); + pendingStats = new Map(); + jest.spyOn(fs.promises, 'lstat').mockImplementation(absolutePath => { + const stat = deferred(); + pendingStats.set(String(absolutePath), stat); + return stat.promise; + }); + + watcher = new NativeWatcher(ROOT, {dot: true, globs: [], ignored: null}); + events = []; + watcher.onFileEvent(event => { + events.push(event); + }); + await watcher.startWatching(); + }); + + afterEach(async () => { + await watcher.stopWatching(); + jest.restoreAllMocks(); + }); + + function settleStat(relativePath: string, result: fs.Stats | Error): void { + const stat = pendingStats.get(join(ROOT, relativePath)); + if (stat == null) { + throw new Error(`No lstat pending for ${relativePath}`); + } + if (result instanceof Error) { + stat.reject(result); + } else { + stat.resolve(result); + } + } + + test('emits events in the order fs.watch reported them, however their stats settle', async () => { + emitFsEvent('rename', join('app', 'moved-in', 'file.js')); + emitFsEvent('rename', join('app', 'moved-in')); + emitFsEvent('change', join('app', 'other.js')); + + // All stats start immediately, before any has settled. + expect([...pendingStats.keys()]).toEqual([ + join(ROOT, 'app', 'moved-in', 'file.js'), + join(ROOT, 'app', 'moved-in'), + join(ROOT, 'app', 'other.js'), + ]); + + // Settle in reverse order. Nothing can be emitted until the first settles. + settleStat(join('app', 'other.js'), fileStat(1000)); + settleStat(join('app', 'moved-in'), enoent()); + await flush(); + expect(events).toEqual([]); + + settleStat(join('app', 'moved-in', 'file.js'), enoent()); + await flush(); + expect(events).toEqual([ + { + event: 'delete', + relativePath: join('app', 'moved-in', 'file.js'), + root: ROOT, + }, + {event: 'delete', relativePath: join('app', 'moved-in'), root: ROOT}, + { + event: 'touch', + relativePath: join('app', 'other.js'), + root: ROOT, + metadata: {type: 'f', modifiedTime: 1000, size: 42}, + }, + ]); + }); + + test('an lstat failure is reported in order and does not block later events', async () => { + const order: Array = []; + watcher.onFileEvent(event => { + order.push(`${event.event}:${event.relativePath}`); + }); + watcher.onError(error => { + order.push(`error:${error.message}`); + }); + + emitFsEvent('change', 'first.js'); + emitFsEvent('change', 'second.js'); + emitFsEvent('change', 'third.js'); + + settleStat('third.js', fileStat(3000)); + settleStat('second.js', new Error('EACCES')); + await flush(); + expect(order).toEqual([]); + + settleStat('first.js', fileStat(1000)); + await flush(); + expect(order).toEqual(['touch:first.js', 'error:EACCES', 'touch:third.js']); + }); +});