diff --git a/flow-typed/node-abort-controller.js b/flow-typed/node-abort-controller.js new file mode 100644 index 0000000000..a498678365 --- /dev/null +++ b/flow-typed/node-abort-controller.js @@ -0,0 +1,61 @@ +/** + * 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 + * @format + */ + +// Translated manually from TS: https://github.com/southpolesteve/node-abort-controller/blob/10e0cea66a069d9319f948d055621e1d37aea5db/index.d.ts + +// `AbortSignal`,`AbortController` are defined here to prevent a dependency on the `dom` library which disagrees with node runtime. +// The definition for `AbortSignal` is taken from @types/node-fetch (https://github.com/DefinitelyTyped/DefinitelyTyped) for +// maximal compatibility with node-fetch. +// Original node-fetch definitions are under MIT License. + +declare module 'node-abort-controller' { + declare export class AbortSignal { + aborted: boolean; + reason?: any; + + addEventListener: ( + type: 'abort', + listener: (this: AbortSignal, event: any) => any, + options?: + | boolean + | { + capture?: boolean, + once?: boolean, + passive?: boolean, + }, + ) => void; + + removeEventListener: ( + type: 'abort', + listener: (this: AbortSignal, event: any) => any, + options?: + | boolean + | { + capture?: boolean, + }, + ) => void; + + dispatchEvent: (event: any) => boolean; + + onabort: null | ((this: AbortSignal, event: any) => void); + + throwIfAborted(): void; + + static abort(reason?: any): AbortSignal; + + static timeout(time: number): AbortSignal; + } + + declare export class AbortController { + signal: AbortSignal; + + abort(reason?: any): void; + } +} diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index c813c4efa2..87ce8b0173 100644 --- a/packages/metro-file-map/package.json +++ b/packages/metro-file-map/package.json @@ -13,7 +13,6 @@ }, "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", "anymatch": "^3.0.3", "debug": "^2.2.0", "fb-watchman": "^2.0.0", @@ -23,6 +22,7 @@ "jest-util": "^27.2.0", "jest-worker": "^27.2.0", "micromatch": "^4.0.4", + "node-abort-controller": "^3.1.1", "nullthrows": "^1.1.1", "walker": "^1.0.7" }, diff --git a/packages/metro-file-map/src/Watcher.js b/packages/metro-file-map/src/Watcher.js index e8f004ca44..7ac019bb8e 100644 --- a/packages/metro-file-map/src/Watcher.js +++ b/packages/metro-file-map/src/Watcher.js @@ -18,6 +18,7 @@ import type { WatchmanClocks, } from './flow-types'; import type {WatcherOptions as WatcherBackendOptions} from './watchers/common'; +import type {AbortSignal} from 'node-abort-controller'; import watchmanCrawl from './crawlers/watchman'; import nodeCrawl from './crawlers/node'; @@ -96,6 +97,9 @@ export class Watcher extends EventEmitter { path.basename(filePath).startsWith(this._options.healthCheckFilePrefix); const crawl = options.useWatchman ? watchmanCrawl : nodeCrawl; let crawler = crawl === watchmanCrawl ? 'watchman' : 'node'; + + options.abortSignal.throwIfAborted(); + const crawlerOptions: CrawlerOptions = { abortSignal: options.abortSignal, computeSha1: options.computeSha1, diff --git a/packages/metro-file-map/src/crawlers/__tests__/node-test.js b/packages/metro-file-map/src/crawlers/__tests__/node-test.js index cfabd96213..c2ce759a57 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/node-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/node-test.js @@ -8,7 +8,7 @@ * @oncall react_native */ -'use strict'; +import {AbortController} from 'node-abort-controller'; jest.useRealTimers(); @@ -395,4 +395,61 @@ describe('node crawler', () => { // once for strawberry.js, once for tomato.js expect(fs.lstat).toHaveBeenCalledTimes(2); }); + + it('aborts the crawl on pre-aborted signal', async () => { + nodeCrawl = require('../node'); + const err = new Error('aborted for test'); + await expect( + nodeCrawl({ + abortSignal: abortSignalWithReason(err), + previousState: { + files: new Map(), + }, + extensions: ['js', 'json'], + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits', '/project/vegtables'], + }), + ).rejects.toThrow(err); + }); + + it('aborts the crawl if signalled after start', async () => { + const err = new Error('aborted for test'); + const abortController = new AbortController(); + + // Pass a fake perf logger that will trigger the abort controller + const fakePerfLogger = { + point(name, opts) { + abortController.abort(err); + }, + annotate() { + abortController.abort(err); + }, + subSpan() { + return fakePerfLogger; + }, + }; + + nodeCrawl = require('../node'); + await expect( + nodeCrawl({ + perfLogger: fakePerfLogger, + abortSignal: abortController.signal, + previousState: { + files: new Map(), + }, + extensions: ['js', 'json'], + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits', '/project/vegtables'], + }), + ).rejects.toThrow(err); + }); }); + +function abortSignalWithReason(reason) { + // TODO: use AbortSignal.abort when node-abort-controller supports it + const controller = new AbortController(); + controller.abort(reason); + return controller.signal; +} diff --git a/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js b/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js index 471b68cb09..1e42d86fdb 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js @@ -8,22 +8,30 @@ * @oncall react_native */ -'use strict'; +import {AbortController} from 'node-abort-controller'; const path = require('path'); jest.mock('fb-watchman', () => { const normalizePathSep = require('../../lib/normalizePathSep').default; const Client = jest.fn(); - Client.prototype.command = jest.fn((args, callback) => + const endedClients = new WeakSet(); + Client.prototype.command = jest.fn(function (args, callback) { + const self = this; setImmediate(() => { + if (endedClients.has(self)) { + callback(new Error('Client has ended')); + return; + } const path = args[1] ? normalizePathSep(args[1]) : undefined; const response = mockResponse[args[0]][path]; callback(null, response.next ? response.next().value : response); - }), - ); + }); + }); Client.prototype.on = jest.fn(); - Client.prototype.end = jest.fn(); + Client.prototype.end = jest.fn(function () { + endedClients.add(this); + }); return {Client}; }); @@ -548,4 +556,61 @@ describe('watchman watch', () => { }), ); }); + + it('aborts the crawl on pre-aborted signal', async () => { + const err = new Error('aborted for test'); + await expect( + watchmanCrawl({ + abortSignal: abortSignalWithReason(err), + previousState: { + clocks: new Map(), + files: new Map(), + }, + extensions: ['js', 'json'], + ignore: pearMatcher, + rootDir: ROOT_MOCK, + roots: ROOTS, + }), + ).rejects.toThrow(err); + }); + + it('aborts the crawl if signalled after start', async () => { + const err = new Error('aborted for test'); + const abortController = new AbortController(); + + // Pass a fake perf logger that will trigger the abort controller + const fakePerfLogger = { + point(name, opts) { + abortController.abort(err); + }, + annotate() { + abortController.abort(err); + }, + subSpan() { + return fakePerfLogger; + }, + }; + + await expect( + watchmanCrawl({ + perfLogger: fakePerfLogger, + abortSignal: abortController.signal, + previousState: { + clocks: new Map(), + files: new Map(), + }, + extensions: ['js', 'json'], + ignore: pearMatcher, + rootDir: ROOT_MOCK, + roots: ROOTS, + }), + ).rejects.toThrow(err); + }); }); + +function abortSignalWithReason(reason) { + // TODO: use AbortSignal.abort when node-abort-controller supports it + const controller = new AbortController(); + controller.abort(reason); + return controller.signal; +} diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index ff42ef7f07..65eb1c71bf 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -175,7 +175,11 @@ module.exports = async function nodeCrawl(options: CrawlerOptions): Promise<{ includeSymlinks, perfLogger, roots, + abortSignal, } = options; + + abortSignal?.throwIfAborted(); + perfLogger?.point('nodeCrawl_start'); const useNativeFind = !forceNodeFilesystemAPI && @@ -184,7 +188,7 @@ module.exports = async function nodeCrawl(options: CrawlerOptions): Promise<{ debug('Using system find: %s', useNativeFind); - return new Promise(resolve => { + return new Promise((resolve, reject) => { const callback = (list: Result) => { const changedFiles = new Map(); const removedFiles = new Map(previousState.files); @@ -208,6 +212,13 @@ module.exports = async function nodeCrawl(options: CrawlerOptions): Promise<{ } perfLogger?.point('nodeCrawl_end'); + + try { + // TODO: Use AbortSignal.reason directly when Flow supports it + abortSignal?.throwIfAborted(); + } catch (e) { + reject(e); + } resolve({ changedFiles, removedFiles, diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 980c56632f..d8ea31acd0 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -62,13 +62,15 @@ module.exports = async function watchmanCrawl({ removedFiles: FileData, clocks: WatchmanClocks, }> { - perfLogger?.point('watchmanCrawl_start'); - - const newClocks = new Map(); + abortSignal?.throwIfAborted(); const client = new watchman.Client(); abortSignal?.addEventListener('abort', () => client.end()); + perfLogger?.point('watchmanCrawl_start'); + + const newClocks = new Map(); + let clientError; client.on('error', error => { clientError = makeWatchmanError(error); @@ -280,6 +282,7 @@ module.exports = async function watchmanCrawl({ }); } perfLogger?.point('watchmanCrawl_end'); + abortSignal?.throwIfAborted(); throw ( queryError ?? clientError ?? new Error('Watchman file results missing') ); @@ -378,6 +381,7 @@ module.exports = async function watchmanCrawl({ perfLogger?.point('watchmanCrawl/processResults_end'); perfLogger?.point('watchmanCrawl_end'); + abortSignal?.throwIfAborted(); return { changedFiles, removedFiles, diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 40c8abac95..181d1cb897 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -13,6 +13,7 @@ import type ModuleMap from './ModuleMap'; import type {PerfLoggerFactory, RootPerfLogger, PerfLogger} from 'metro-config'; +import type {AbortSignal} from 'node-abort-controller'; export type {PerfLoggerFactory, PerfLogger}; diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index ac1be5ed08..ded8f1b1c0 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -56,8 +56,7 @@ import invariant from 'invariant'; import {escapePathForRegex} from 'jest-regex-util'; import {Worker} from 'jest-worker'; import * as path from 'path'; -// $FlowFixMe[untyped-import] - this is a polyfill -import AbortController from 'abort-controller'; +import {AbortController} from 'node-abort-controller'; import {performance} from 'perf_hooks'; import nullthrows from 'nullthrows'; @@ -244,7 +243,7 @@ export default class HasteMap extends EventEmitter { _watcher: ?Watcher; _worker: ?WorkerInterface; _cacheManager: CacheManager; - _crawlerAbortController: typeof AbortController; + _crawlerAbortController: AbortController; _healthCheckInterval: ?IntervalID; _startupPerfLogger: ?PerfLogger; @@ -1164,11 +1163,12 @@ export default class HasteMap extends EventEmitter { clearInterval(this._healthCheckInterval); } + this._crawlerAbortController.abort(); + if (!this._watcher) { return; } await this._watcher.close(); - this._crawlerAbortController.abort(); } /** diff --git a/yarn.lock b/yarn.lock index 964a944606..29b191b378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1353,13 +1353,6 @@ "@typescript-eslint/types" "5.30.5" eslint-visitor-keys "^3.3.0" -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - accepts@^1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -2673,11 +2666,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - exec-sh@^0.3.2: version "0.3.4" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" @@ -4704,6 +4692,11 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-abort-controller@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" + integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== + node-fetch@^2.2.0: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"