diff --git a/packages/metro-file-map/src/Watcher.js b/packages/metro-file-map/src/Watcher.js index ddba02c2a9..b28cb761fd 100644 --- a/packages/metro-file-map/src/Watcher.js +++ b/packages/metro-file-map/src/Watcher.js @@ -322,13 +322,34 @@ export class Watcher extends EventEmitter { clocks: rootClocks, }; const watcher: WatcherBackend = new WatcherImpl(root, watcherOptions); + const reportedResourceLimits = new Set(); - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { const rejectTimeout = setTimeout( () => reject(new Error('Failed to start watch mode.')), MAX_WAIT_TIME, ); + watcher.onError(error => { + const code = (error as interface {readonly code?: unknown}).code; + let guidance = ''; + if (code === 'ENOSPC' || code === 'EMFILE') { + // A resource limit can fail once per directory in a large crawl. + if (reportedResourceLimits.has(code)) { + return; + } + reportedResourceLimits.add(code); + guidance = + ' Changes in unwatched directories will not be detected. ' + + (code === 'ENOSPC' + ? 'Increase fs.inotify.max_user_watches on Linux.' + : 'Increase the open file limit (ulimit -n).'); + } + this.#options.console.error( + `metro-file-map: ${this.#activeWatcher ?? 'unknown'} watcher error in ${root}:${guidance}`, + error, + ); + }); watcher.onFileEvent(change => { const basename = path.basename(change.relativePath); if (basename.startsWith(this.#options.healthCheckFilePrefix)) { @@ -358,9 +379,18 @@ export class Watcher extends EventEmitter { this.#bufferedEvents.push(change); } }); - await watcher.startWatching(); - clearTimeout(rejectTimeout); - resolve(watcher); + void Promise.resolve() + .then(() => watcher.startWatching()) + .then( + () => { + clearTimeout(rejectTimeout); + resolve(watcher); + }, + error => { + clearTimeout(rejectTimeout); + reject(error); + }, + ); }); }; diff --git a/packages/metro-file-map/src/__tests__/Watcher-test.js b/packages/metro-file-map/src/__tests__/Watcher-test.js index cda12f91eb..7c13aeaa28 100644 --- a/packages/metro-file-map/src/__tests__/Watcher-test.js +++ b/packages/metro-file-map/src/__tests__/Watcher-test.js @@ -20,6 +20,7 @@ import type { import TreeFS from '../lib/TreeFS'; import {Watcher} from '../Watcher'; +import EventEmitter from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -39,14 +40,17 @@ type MockBackend = { root: string, clocks: ?Map, emit: (event: WatcherBackendChangeEvent) => void, + emitError: (error: Error) => void, }; const mockStartedBackends: Array = []; +const mockStartWatching = jest.fn<[string], Promise>(); let mockNativeLiveOnStart = true; function mockBackendClass(kind: MockBackend['kind']) { return class { #root: string; #clocks: ?Map; #listener: ?(event: WatcherBackendChangeEvent) => void; + #emitter: EventEmitter = new EventEmitter(); static isSupported(): boolean { return true; } @@ -60,13 +64,23 @@ function mockBackendClass(kind: MockBackend['kind']) { onFileEvent(listener: (event: WatcherBackendChangeEvent) => void) { this.#listener = listener; } - async startWatching() { + onError(listener: (error: Error) => void): () => void { + this.#emitter.on('error', listener); + return () => { + this.#emitter.removeListener('error', listener); + }; + } + startWatching(): Promise { mockStartedBackends.push({ kind, root: this.#root, clocks: this.#clocks, emit: event => this.#listener?.(event), + emitError: error => { + this.#emitter.emit('error', error); + }, }); + return mockStartWatching(this.#root); } async stopWatching() {} getPauseReason(): ?string { @@ -144,13 +158,106 @@ beforeEach(() => { mockNativeLiveOnStart = true; mockWatchmanCrawl.mockReset(); mockNodeCrawl.mockReset(); + mockStartWatching.mockReset(); + mockStartWatching.mockImplementation(async () => {}); jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { + jest.clearAllTimers(); jest.restoreAllMocks(); }); +describe('Watcher startup rejection', () => { + test.each([ + [false, 'crawl'], + [false, 'watch'], + [true, 'watch'], + ])( + 'propagates a backend rejection from %s/%s before the timeout', + async (useWatchman, operation) => { + const error = new Error('Backend could not start'); + mockStartWatching.mockImplementationOnce(async () => { + throw error; + }); + const watcher = createWatcher(useWatchman, null, true); + const outcomes: Array = []; + const starting = ( + operation === 'crawl' ? watcher.crawl() : watcher.watch(() => {}) + ).then( + () => outcomes.push(null), + (error: Error) => outcomes.push(error), + ); + + // $FlowFixMe[prop-missing] Available in Jest 29, missing from its libdef. + await jest.advanceTimersByTimeAsync(0); + + expect(outcomes).toEqual([error]); + expect(jest.getTimerCount()).toBe(0); + await starting; + await watcher.close(); + }, + ); + + test('propagates a failure from watchRoot without leaving a timer', async () => { + const watcher = createWatcher(true, null, true); + await watcher.watch(() => {}); + const error = new Error('Added root could not start'); + mockStartWatching.mockImplementationOnce(async () => { + throw error; + }); + const outcomes: Array = []; + const starting = watcher.watchRoot(path.join(rootDir, 'added')).then( + () => outcomes.push(null), + (error: Error) => outcomes.push(error), + ); + + // $FlowFixMe[prop-missing] Available in Jest 29, missing from its libdef. + await jest.advanceTimersByTimeAsync(0); + + expect(outcomes).toEqual([error]); + expect(jest.getTimerCount()).toBe(0); + await starting; + await watcher.close(); + }); + + test('propagates a synchronous startup error without leaving a timer', async () => { + const error = new Error('Synchronous startup failure'); + mockStartWatching.mockImplementationOnce(() => { + throw error; + }); + const watcher = createWatcher(true, null, true); + + await expect(watcher.watch(() => {})).rejects.toBe(error); + expect(jest.getTimerCount()).toBe(0); + await watcher.close(); + }); + + test('retains the existing deadline for a startup that never settles', async () => { + mockStartWatching.mockImplementationOnce(() => new Promise(() => {})); + const watcher = createWatcher(true, null, true); + const outcomes: Array = []; + const starting = watcher + .watch(() => {}) + .then( + () => outcomes.push(null), + (error: Error) => outcomes.push(error), + ); + + // $FlowFixMe[prop-missing] Available in Jest 29, missing from its libdef. + await jest.advanceTimersByTimeAsync(239999); + expect(outcomes).toEqual([]); + // $FlowFixMe[prop-missing] Available in Jest 29, missing from its libdef. + await jest.advanceTimersByTimeAsync(1); + + expect(outcomes).toHaveLength(1); + expect(outcomes[0]?.message).toBe('Failed to start watch mode.'); + expect(jest.getTimerCount()).toBe(0); + await starting; + await watcher.close(); + }); +}); + describe('Watcher crawl fallback', () => { test('the node crawler runs directly when Watchman is not preferred', async () => { const canonicalPath = path.join('src', 'index.js'); @@ -234,6 +341,32 @@ describe('Watcher backend choice in watch mode', () => { metadata: {modifiedTime: 1, size: 1, type: 'f'}, }; + test.each([false, true])( + 'reports backend errors without losing later changes (useWatchman=%s)', + async useWatchman => { + mockNodeCrawl.mockImplementation(async () => crawlResult([])); + mockWatchmanCrawl.mockImplementation(async () => crawlResult([])); + const reportError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const watcher = createWatcher(useWatchman, null, true); + const onChange = jest.fn<[WatcherBackendChangeEvent], void>(); + await watcher.crawl(); + await watcher.watch(onChange); + + const backend = mockStartedBackends[0]; + const error = new Error('Cannot read watched directory'); + expect(() => backend.emitError(error)).not.toThrow(); + expect(reportError).toHaveBeenCalledWith( + `metro-file-map: ${backend.kind} watcher error in ${rootDir}:`, + error, + ); + backend.emit(event); + expect(onChange).toHaveBeenCalledWith(event); + await watcher.close(); + }, + ); + test('a successful Watchman crawl starts a Watchman backend only on watch()', async () => { mockWatchmanCrawl.mockImplementation(async () => crawlResult([])); const watcher = createWatcher(true, null, true); diff --git a/packages/metro-file-map/src/__tests__/WatcherErrors-test.js b/packages/metro-file-map/src/__tests__/WatcherErrors-test.js new file mode 100644 index 0000000000..c8b3ddc514 --- /dev/null +++ b/packages/metro-file-map/src/__tests__/WatcherErrors-test.js @@ -0,0 +1,191 @@ +/** + * 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 TreeFS from '../lib/TreeFS'; +import {Watcher} from '../Watcher'; +import FallbackWatcher from '../watchers/FallbackWatcher'; +import NativeWatcher from '../watchers/NativeWatcher'; +import EventEmitter from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import nullthrows from 'nullthrows'; + +jest.useRealTimers(); + +class MockFSWatcher extends EventEmitter { + close() { + this.emit('close'); + } +} + +class FilesystemError extends Error { + code: string; + + constructor(code: string) { + super('Watch handle failed'); + this.code = code; + } +} + +describe('Watcher resource-limit reporting', () => { + let root: string; + let watcher: Watcher; + + beforeEach(async () => { + root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'metro-errors-')); + jest.spyOn(NativeWatcher, 'isSupported').mockReturnValue(false); + jest.spyOn(FallbackWatcher, 'isLiveOnStart').mockReturnValue(true); + watcher = new Watcher({ + abortSignal: new AbortController().signal, + computeSha1: false, + console, + enableSymlinks: false, + extensions: ['js'], + healthCheckFilePrefix: '.health', + ignoreForCrawl: () => false, + ignorePatternForWatch: /^$/, + perfLogger: null, + previousState: { + clocks: new Map(), + fileSystem: new TreeFS({ + processFile: () => { + throw new Error('not implemented'); + }, + rootDir: root, + }), + }, + rootDir: root, + roots: [root], + useWatchman: false, + watch: true, + watchmanDeferStates: [], + }); + }); + + afterEach(async () => { + await watcher.close(); + jest.restoreAllMocks(); + await fs.promises.rm(root, {recursive: true, force: true}); + }); + + test('rejects with the native filesystem error when the watched root is missing', async () => { + jest.spyOn(NativeWatcher, 'isSupported').mockReturnValue(true); + await fs.promises.rmdir(root); + jest.useFakeTimers(); + try { + const failure = watcher.watch(() => {}).catch(error => error); + + // $FlowFixMe[prop-missing] Available in Jest 29, missing from its libdef. + await jest.advanceTimersByTimeAsync(240000); + + expect(await failure).toMatchObject({code: 'ENOENT'}); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test.each(['ENOSPC', 'EMFILE'])( + 'reports %s once while still crawling every unwatched directory', + async code => { + const directories = Array.from({length: 128}, (_, i) => + path.join(root, `dir-${i}`), + ); + await Promise.all(directories.map(dir => fs.promises.mkdir(dir))); + const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(dir => { + const error = new Error(`Cannot watch ${String(dir)}`); + // $FlowFixMe[prop-missing] Node filesystem errors include a code. + error.code = code; + throw error; + }); + const readdir = jest.spyOn(fs.promises, 'readdir'); + const reportError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await watcher.watch(() => {}); + + expect(watchSpy.mock.calls.length).toBeGreaterThanOrEqual(129); + for (const dir of directories) { + expect(readdir).toHaveBeenCalledWith(dir); + } + expect(reportError.mock.calls).toEqual([ + [expect.any(String), expect.objectContaining({code})], + ]); + expect(reportError.mock.calls[0][0]).toContain('unwatched directories'); + expect(reportError.mock.calls[0][0]).toContain( + code === 'ENOSPC' ? 'fs.inotify.max_user_watches' : 'ulimit -n', + ); + expect(reportError.mock.calls[0][1]).toMatchObject({code}); + + const otherRoot = path.join(root, 'another-root'); + await fs.promises.mkdir(otherRoot); + await watcher.watchRoot(otherRoot); + expect(reportError).toHaveBeenCalledTimes(2); + }, + ); + + test.each(['EIO', 'ENOSPC', 'EMFILE'])( + 'reports a lost directory watch with its path and recovery guidance (%s)', + async code => { + const directory = path.join(root, 'feature'); + await fs.promises.mkdir(directory); + const handles = new Map(); + jest.spyOn(fs, 'watch').mockImplementation(dir => { + const handle = new MockFSWatcher(); + handles.set(String(dir), handle); + return handle; + }); + const reportError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await watcher.watch(() => {}); + const error = new FilesystemError(code); + const handle = nullthrows(handles.get(directory)); + + expect(() => handle.emit('error', error)).not.toThrow(); + + expect(reportError).toHaveBeenCalledTimes(1); + const reported = reportError.mock.calls[0][1]; + expect(reported.message).toContain(directory); + expect(reported.message).toContain( + 'Changes in this directory will not be detected', + ); + expect(reported.message).toContain('Restart Metro'); + expect(reported.cause).toBe(error); + expect(reported.code).toBe(code); + + if (code !== 'EIO') { + nullthrows(handles.get(root)).emit('error', new FilesystemError(code)); + expect(reportError).toHaveBeenCalledTimes(1); + } + + await watcher.close(); + expect(() => handle.emit('error', error)).not.toThrow(); + expect(reportError).toHaveBeenCalledTimes(1); + }, + ); + + test('ignores a lost watch for a directory that no longer exists', async () => { + const handle = new MockFSWatcher(); + jest.spyOn(fs, 'watch').mockReturnValue(handle); + const reportError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await watcher.watch(() => {}); + + handle.emit('error', new FilesystemError('ENOENT')); + + expect(reportError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/metro-file-map/src/watchers/AbstractWatcher.js b/packages/metro-file-map/src/watchers/AbstractWatcher.js index 0f50bc52dd..bf0f38f79c 100644 --- a/packages/metro-file-map/src/watchers/AbstractWatcher.js +++ b/packages/metro-file-map/src/watchers/AbstractWatcher.js @@ -74,6 +74,9 @@ export class AbstractWatcher implements WatcherBackend { async stopWatching(): Promise { this.#emitter.removeAllListeners(); + // An in-flight filesystem operation can still report an error after the + // owner has detached. Do not turn that late error into an uncaught throw. + this.#emitter.on('error', () => {}); } emitFileEvent(event: Omit) { diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index dba8ac4b47..bd4dbb9f3d 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -47,6 +47,20 @@ const DEBOUNCE_MS = 100; */ const CRAWL_CONCURRENCY = 32; +class WatchLostError extends Error { + code: unknown; + + constructor(dir: string, cause: Error) { + super( + `Watch lost for ${dir}: ${cause.message}. ` + + 'Changes in this directory will not be detected. ' + + 'Restart Metro to resume watching.', + {cause}, + ); + this.code = (cause as interface {readonly code?: unknown}).code; + } +} + export default class FallbackWatcher extends AbstractWatcher { readonly #changeTimers: Map> = new Map(); @@ -220,7 +234,7 @@ export default class FallbackWatcher extends AbstractWatcher { if (this.#watched[dir] === watcher) { delete this.#watched[dir]; } - this.#checkedEmitError(error); + this.#checkedEmitError(new WatchLostError(dir, error)); }); if (this.root !== dir) { diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index 669f9fb32d..fdf7c5a671 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -115,6 +115,7 @@ export default class NativeWatcher extends AbstractWatcher { }); }, ); + this.#fsWatcher.on('error', error => this.emitError(error)); debug('Watching %s', this.root); } diff --git a/packages/metro-file-map/src/watchers/WatchmanWatcher.js b/packages/metro-file-map/src/watchers/WatchmanWatcher.js index 6dbe5582e0..15be59f844 100644 --- a/packages/metro-file-map/src/watchers/WatchmanWatcher.js +++ b/packages/metro-file-map/src/watchers/WatchmanWatcher.js @@ -37,12 +37,17 @@ const debug = debugModule('Metro:WatchmanWatcher'); const DELETE_EVENT = common.DELETE_EVENT; const TOUCH_EVENT = common.TOUCH_EVENT; const SUB_PREFIX = 'metro-file-map'; +const INITIAL_RECONNECT_DELAY = 1000; +const MAX_RECONNECT_DELAY = 30000; /** * Watches `dir`. */ export default class WatchmanWatcher extends AbstractWatcher { - #client: Client; + #client: ?Client = null; + #closed: boolean = false; + #reconnectTimer: ?ReturnType; + #reconnectDelay: number = INITIAL_RECONNECT_DELAY; readonly subscriptionName: string; #watchProjectInfo: ?Readonly<{ relativePath: string, @@ -78,26 +83,55 @@ export default class WatchmanWatcher extends AbstractWatcher { * Run the watchman `watch` command on the root and subscribe to changes. */ #init(onReady: () => void, onError: (error: Error) => void) { + if (this.#closed) { + onError(new Error('Watcher closed during startup.')); + return; + } if (this.#client) { - this.#client.removeAllListeners(); + stopClient(this.#client); } const self = this; - this.#client = new watchman.Client(); - this.#client.on('error', error => { - this.emitError(error); + const client = new watchman.Client(); + this.#client = client; + let ready = false; + let failed = false; + const isActive = () => !this.#closed && !failed && this.#client === client; + const onFailure = (error: Error) => { + if (!isActive()) { + return; + } + failed = true; + this.#client = null; + stopClient(client); + if (ready) { + this.emitError(error); + this.#scheduleReconnect(); + } else { + onError(error); + } + }; + client.on('error', onFailure); + client.on('subscription', changeEvent => { + if (isActive()) { + this.#handleChangeEvent(changeEvent); + } }); - this.#client.on('subscription', changeEvent => - this.#handleChangeEvent(changeEvent), - ); - this.#client.on('end', () => { + client.on('end', () => { + if (!isActive()) { + return; + } + if (!ready) { + onFailure(new Error('Watchman connection closed during startup.')); + return; + } + failed = true; + this.#client = null; + stopClient(client); console.warn( '[metro-file-map] Warning: Lost connection to Watchman, reconnecting..', ); - self.#init( - () => {}, - error => self.emitError(error), - ); + this.#scheduleReconnect(); }); this.#watchProjectInfo = null; @@ -107,8 +141,11 @@ export default class WatchmanWatcher extends AbstractWatcher { } function onWatchProject(error: ?Error, resp: WatchmanWatchResponse) { + if (!isActive()) { + return; + } if (error) { - onError(error); + onFailure(error); return; } debug('Received watch-project response: %s', resp.relative_path); @@ -134,13 +171,16 @@ export default class WatchmanWatcher extends AbstractWatcher { if (sinceClock != null) { subscribe(sinceClock); } else { - self.#client.command(['clock', getWatchRoot()], onClock); + client.command(['clock', getWatchRoot()], onClock); } } function onClock(error: ?Error, resp: WatchmanClockResponse) { + if (!isActive()) { + return; + } if (error) { - onError(error); + onFailure(error); return; } @@ -164,15 +204,18 @@ export default class WatchmanWatcher extends AbstractWatcher { relative_root: watchProjectInfo.relativePath, }; - self.#client.command( + client.command( ['subscribe', getWatchRoot(), self.subscriptionName, options], onSubscribe, ); } const onSubscribe = (error: ?Error, resp: WatchmanSubscribeResponse) => { + if (!isActive()) { + return; + } if (error) { - onError(error); + onFailure(error); return; } debug('Received subscribe response: %s', resp.subscribe); @@ -183,10 +226,36 @@ export default class WatchmanWatcher extends AbstractWatcher { this.#deferringStates = new Set(resp['asserted-states']); } + ready = true; + this.#reconnectDelay = INITIAL_RECONNECT_DELAY; onReady(); }; - self.#client.command(['watch-project', getWatchRoot()], onWatchProject); + client.command(['watch-project', getWatchRoot()], onWatchProject); + } + + #scheduleReconnect() { + if (this.#closed || this.#reconnectTimer != null) { + return; + } + const delay = this.#reconnectDelay; + this.#reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_DELAY); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#init( + () => { + console.warn( + `[metro-file-map] Warning: Reconnected to Watchman for ${this.root}. ` + + 'Changes made while disconnected may have been missed. ' + + 'Restart Metro to resynchronize the file map.', + ); + }, + error => { + this.emitError(error); + this.#scheduleReconnect(); + }, + ); + }, delay); } /** @@ -324,10 +393,16 @@ export default class WatchmanWatcher extends AbstractWatcher { * Closes the watcher. */ async stopWatching(): Promise { + this.#closed = true; + if (this.#reconnectTimer != null) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } await super.stopWatching(); if (this.#client) { - this.#client.removeAllListeners(); - this.#client.end(); + const client = this.#client; + this.#client = null; + stopClient(client); } this.#deferringStates = null; } @@ -347,6 +422,14 @@ export default class WatchmanWatcher extends AbstractWatcher { } } +function stopClient(client: Client) { + client.removeAllListeners(); + // Discovery can finish after end(), so also close any late socket. + client.on('connect', () => client.end()); + client.on('error', () => client.end()); + client.end(); +} + /** * Handles a warning in the watchman resp object. */ diff --git a/packages/metro-file-map/src/watchers/__tests__/AbstractWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/AbstractWatcher-test.js new file mode 100644 index 0000000000..19db314f1f --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/AbstractWatcher-test.js @@ -0,0 +1,33 @@ +/** + * 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 + */ + +import {AbstractWatcher} from '../AbstractWatcher'; +import * as path from 'node:path'; + +test('stopping detaches error listeners and tolerates errors from in-flight work', async () => { + const watcher = new AbstractWatcher(path.join(path.sep, 'project'), { + ignored: null, + included: null, + }); + const onError = jest.fn<[Error], void>(); + const removeListener = watcher.onError(onError); + const error = new Error('watcher error'); + watcher.emitError(error); + expect(onError).toHaveBeenCalledWith(error); + onError.mockClear(); + + await watcher.stopWatching(); + removeListener(); + expect(() => watcher.emitError(error)).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + + await watcher.stopWatching(); + expect(() => watcher.emitError(error)).not.toThrow(); +}); diff --git a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js index b4e936c9d1..6bd7a8e07e 100644 --- a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js @@ -12,6 +12,7 @@ import type {WatcherBackendChangeEvent} from '../../flow-types'; import NativeWatcher from '../NativeWatcher'; +import EventEmitter from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import {join, resolve} from 'node:path'; @@ -59,17 +60,23 @@ function enoent(): Error { // Run every pending promise continuation, including those queued by others. const flush = () => new Promise(resolve => setImmediate(resolve)); +class MockFSWatcher extends EventEmitter { + close: JestMockFn<[], void> = jest.fn(); +} + describe('NativeWatcher', () => { let watcher: NativeWatcher; let emitFsEvent: (event: string, relativePath: string) => void; let pendingStats: Map>; let events: Array; + let fsWatcher: MockFSWatcher; beforeEach(async () => { jest.spyOn(os, 'platform').mockReturnValue('darwin'); + fsWatcher = new MockFSWatcher(); jest.spyOn(fs, 'watch').mockImplementation((_root, _opts, listener) => { emitFsEvent = listener; - return {close: () => {}}; + return fsWatcher; }); pendingStats = new Map(); jest.spyOn(fs.promises, 'lstat').mockImplementation(absolutePath => { @@ -106,6 +113,27 @@ describe('NativeWatcher', () => { } } + test('forwards FSWatcher errors to the backend error listener', () => { + const onError = jest.fn(); + watcher.onError(onError); + const error = new Error('Native watch failed'); + + expect(() => fsWatcher.emit('error', error)).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + }); + + test('absorbs FSWatcher errors after stopping', async () => { + const onError = jest.fn(); + watcher.onError(onError); + await watcher.stopWatching(); + + expect(() => + fsWatcher.emit('error', new Error('Late native watch failure')), + ).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + }); + 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')); diff --git a/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-reconnect-test.js b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-reconnect-test.js new file mode 100644 index 0000000000..049f3960f1 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-reconnect-test.js @@ -0,0 +1,123 @@ +/** + * 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 WatchmanWatcher from '../WatchmanWatcher'; +// $FlowFixMe[untyped-import] Exercise the real client's wire protocol. +import {dumpToBuffer} from 'bser'; // eslint-disable-line import/no-extraneous-dependencies -- fb-watchman's wire encoder +import * as childProcess from 'child_process'; // eslint-disable-line import/enforce-node-protocol-usage -- match fb-watchman's module identifier +import * as net from 'net'; // eslint-disable-line import/enforce-node-protocol-usage -- match fb-watchman's module identifier +import EventEmitter from 'node:events'; + +jest.unmock('fb-watchman'); +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawn: jest.fn(), +})); +jest.mock('net', () => ({ + ...jest.requireActual('net'), + createConnection: jest.fn(), +})); + +class DiscoveryProcess extends EventEmitter { + stdout: EventEmitter = new EventEmitter(); + stderr: EventEmitter = new EventEmitter(); +} + +class Socket extends EventEmitter { + end: JestMockFn<[], void> = jest.fn(); + write: JestMockFn<[Buffer], void> = jest.fn(); +} + +describe('Watchman reconnect discovery after shutdown', () => { + let originalSock: ?string; + let watcher: WatchmanWatcher; + let discovery: DiscoveryProcess; + let socket: Socket; + + beforeEach(() => { + jest.clearAllMocks(); + originalSock = process.env.WATCHMAN_SOCK; + delete process.env.WATCHMAN_SOCK; + discovery = new DiscoveryProcess(); + socket = new Socket(); + // $FlowFixMe[incompatible-type] Model only the client's process boundary. + jest.spyOn(childProcess, 'spawn').mockReturnValue(discovery); + // $FlowFixMe[incompatible-type] Model only the client's socket boundary. + jest.spyOn(net, 'createConnection').mockReturnValue(socket); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + watcher = new WatchmanWatcher('/project', { + ignored: null, + included: null, + watchmanDeferStates: [], + }); + }); + + afterEach(async () => { + await watcher.stopWatching(); + jest.clearAllTimers(); + jest.restoreAllMocks(); + if (originalSock == null) { + delete process.env.WATCHMAN_SOCK; + } else { + process.env.WATCHMAN_SOCK = originalSock; + } + }); + + test.each(['connect', 'error'])( + 'ends a socket that reports %s after reconnect discovery finishes late', + async event => { + const onError = jest.fn(); + watcher.onError(onError); + const started = watcher.startWatching(); + discovery.stdout.emit( + 'data', + JSON.stringify({sockname: '/initial.sock'}), + ); + discovery.emit('close', 0, null); + socket.emit('connect'); + for (const response of [ + {watch: '/project'}, + {clock: 'c:123'}, + {subscribe: watcher.subscriptionName}, + ]) { + socket.emit('data', dumpToBuffer(response)); + jest.runAllTicks(); + } + await started; + + const reconnectDiscovery = new DiscoveryProcess(); + const lateSocket = new Socket(); + // $FlowFixMe[incompatible-type] Model only the client's process boundary. + jest.spyOn(childProcess, 'spawn').mockReturnValue(reconnectDiscovery); + // $FlowFixMe[incompatible-type] Model only the client's socket boundary. + jest.spyOn(net, 'createConnection').mockReturnValue(lateSocket); + socket.emit('end'); + jest.advanceTimersByTime(1000); + await watcher.stopWatching(); + + reconnectDiscovery.stdout.emit( + 'data', + JSON.stringify({sockname: '/late.sock'}), + ); + reconnectDiscovery.emit('close', 0, null); + expect(net.createConnection).toHaveBeenLastCalledWith('/late.sock'); + expect(() => + lateSocket.emit(event, new Error('Late error')), + ).not.toThrow(); + expect(lateSocket.end).toHaveBeenCalledTimes(1); + expect(lateSocket.write).not.toHaveBeenCalled(); + lateSocket.emit('end'); + jest.advanceTimersByTime(60000); + expect(childProcess.spawn).toHaveBeenCalledTimes(2); + expect(onError).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js index 02e8aeb925..99ee50062e 100644 --- a/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js @@ -20,8 +20,9 @@ import EventEmitter from 'node:events'; class MockClient extends EventEmitter { command: JestMockFn, unknown> = jest.fn(); + end: JestMockFn<[], void> = jest.fn(); } -const mockClient = new MockClient(); +let mockClient = new MockClient(); const cmdCallback = (err: ?Error, result: Partial): void => { expect(mockClient.command.mock.lastCall[1]).toEqual(expect.any(Function)); @@ -43,10 +44,205 @@ const wp: string => string = filePath => process.platform === 'win32' ? filePath.replace(/^\//, 'C:/') : filePath; jest.mock('fb-watchman', () => ({ - Client: jest.fn().mockImplementation(() => mockClient), + Client: jest.fn().mockImplementation(() => (mockClient = new MockClient())), })); describe('WatchmanWatcher', () => { + describe('connection lifecycle', () => { + let watcher: WatchmanWatcher; + let onError: JestMockFn<[Error], void>; + + function completeSubscription() { + cmdCallback(null, {watch: wp('/project')}); + cmdCallback(null, {clock: 'c:123'}); + cmdCallback(null, {'asserted-states': []}); + } + + beforeEach(async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + watcher = new WatchmanWatcher(p('/project'), { + ignored: null, + included: null, + watchmanDeferStates: [], + }); + onError = jest.fn(); + watcher.onError(onError); + const started = watcher.startWatching(); + completeSubscription(); + await started; + }); + + afterEach(async () => { + await watcher.stopWatching(); + jest.clearAllTimers(); + jest.restoreAllMocks(); + }); + + test('does not warn about missed changes on initial startup', () => { + expect(console.warn).not.toHaveBeenCalled(); + }); + + test.each(['end', 'error'])( + 'warns once about missed changes after recovering from %s', + event => { + const warn = jest.spyOn(console, 'warn'); + mockClient.emit(event, new Error('Connection lost')); + warn.mockClear(); + jest.advanceTimersByTime(1000); + mockClient.emit('error', new Error('Still unavailable')); + expect(console.warn).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2000); + completeSubscription(); + + expect(console.warn).toHaveBeenCalledTimes(1); + const warning = warn.mock.calls[0][0]; + expect(warning).toContain(p('/project')); + expect(warning).toContain( + 'Changes made while disconnected may have been missed', + ); + expect(warning).toContain('Restart Metro'); + + mockClient.emit('subscription', { + subscription: watcher.subscriptionName, + files: [], + }); + expect(console.warn).toHaveBeenCalledTimes(1); + }, + ); + + test('warns again after a separate connection outage recovers', () => { + for (let outage = 0; outage < 2; outage++) { + mockClient.emit('error', new Error('Connection lost')); + jest.advanceTimersByTime(1000); + completeSubscription(); + } + expect(console.warn).toHaveBeenCalledTimes(2); + }); + + test('does not report recovery from a retired client', async () => { + const warn = jest.spyOn(console, 'warn'); + mockClient.emit('end'); + jest.advanceTimersByTime(1000); + cmdCallback(null, {watch: wp('/project')}); + cmdCallback(null, {clock: 'c:123'}); + const onSubscribe = mockClient.command.mock.lastCall[1]; + warn.mockClear(); + + await watcher.stopWatching(); + onSubscribe(null, {}); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + test.each(['transport', 'watch-project', 'clock', 'subscribe'])( + 'recovers after a failed reconnect at %s', + stage => { + const firstClient = mockClient; + firstClient.emit('end'); + jest.advanceTimersByTime(1000); + const failedClient = mockClient; + const error = new Error('Watchman unavailable'); + if (stage === 'transport') { + failedClient.emit('error', error); + } else { + if (stage !== 'watch-project') { + cmdCallback(null, {watch: wp('/project')}); + } + if (stage === 'subscribe') { + cmdCallback(null, {clock: 'c:123'}); + } + cmdCallback(error, {}); + } + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + expect(mockClient).toBe(failedClient); + + jest.advanceTimersByTime(60000); + expect(mockClient).not.toBe(failedClient); + completeSubscription(); + const onFileEvent = jest.fn(); + watcher.onFileEvent(onFileEvent); + mockClient.emit('subscription', { + subscription: watcher.subscriptionName, + clock: 'c:124', + files: [{name: 'deleted.js', exists: false}], + }); + expect(onFileEvent).toHaveBeenCalledWith({ + event: 'delete', + relativePath: 'deleted.js', + root: p('/project'), + clock: [p('/project'), 'c:124'], + }); + }, + ); + + test('reconnects after an established client errors without an end event', () => { + const failedClient = mockClient; + failedClient.emit('error', new Error('Connection reset')); + expect(mockClient).toBe(failedClient); + jest.advanceTimersByTime(1000); + expect(mockClient).not.toBe(failedClient); + completeSubscription(); + }); + + test('backs off repeated failures and resets the delay after recovery', () => { + mockClient.emit('error', new Error('Connection reset')); + const firstClient = mockClient; + jest.advanceTimersByTime(999); + expect(mockClient).toBe(firstClient); + jest.advanceTimersByTime(1); + const secondClient = mockClient; + secondClient.emit('error', new Error('Still unavailable')); + jest.advanceTimersByTime(1999); + expect(mockClient).toBe(secondClient); + jest.advanceTimersByTime(1); + expect(mockClient).not.toBe(secondClient); + completeSubscription(); + const recoveredClient = mockClient; + recoveredClient.emit('error', new Error('Connection reset again')); + jest.advanceTimersByTime(1000); + expect(mockClient).not.toBe(recoveredClient); + }); + + test('absorbs late errors from a replaced client', () => { + const oldClient = mockClient; + oldClient.emit('end'); + jest.advanceTimersByTime(1000); + expect(() => + oldClient.emit('error', new Error('Late error')), + ).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + }); + + test('absorbs late client errors after stopping', async () => { + await watcher.stopWatching(); + expect(() => + mockClient.emit('error', new Error('Late error')), + ).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + }); + + test('cancels a scheduled reconnect on stop', async () => { + const oldClient = mockClient; + oldClient.emit('end'); + await watcher.stopWatching(); + jest.advanceTimersByTime(60000); + expect(mockClient).toBe(oldClient); + }); + + test('ignores an in-flight reconnect response after stopping', async () => { + mockClient.emit('end'); + jest.advanceTimersByTime(1000); + const client = mockClient; + const callback = client.command.mock.lastCall[1]; + await watcher.stopWatching(); + client.command.mockClear(); + callback(null, {watch: wp('/project')}); + expect(client.command).not.toHaveBeenCalled(); + }); + }); + test('initializes with watch-project, clock, subscribe', () => { const watchmanWatcher = new WatchmanWatcher(p('/project/subdir/js'), { ignored: null,