From d38e529777acb75be04bfeb640dbf896c64eb549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 21 Apr 2026 05:05:16 -0700 Subject: [PATCH] Harden Metro bundle retry against file-watcher races (#56530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56530 Each Fantom test writes a unique `\-.js` entrypoint into `.out/js-builds/` and then asks Metro to bundle it. Metro's file watcher (metro-file-map's `FallbackWatcher` on Linux, debounced 100 ms) does not always observe the new entrypoint by the time the HTTP request arrives, especially when multiple workers are writing entrypoints concurrently. The previous retry logic was too narrow: - Only HTTP 404 was treated as retryable. Metro returns 404 only when the entry file path itself can't be resolved; an unresolved transitive dep (e.g. `setUpDefaultReactNativeEnvironment`) returns HTTP 500 with `{type: 'UnableToResolveError'}` — we'd throw immediately on that. - Only 3 attempts with a flat 500 ms wait (~1 s total), which is not enough on a busy host with 8 workers writing entrypoints at once. This results in ~30 spurious "Failed to request bundle from Metro: Unable to resolve module ..." failures per run. Refactor `createBundle` into a focused `fetchBundleWithRetry` helper that: - Parses Metro's JSON error envelope (`{type, message, ...}` from `formatBundlingError`) once per response and uses `type` to decide whether to retry. Retries on HTTP 404, on HTTP 500 with `UnableToResolveError` or `ResourceNotFoundError`, and on transient `fetch` network errors. All other failures (transform errors, syntax errors, real config issues) throw immediately so we don't waste seconds on them. - Uses exponential backoff (100 ms → 200 → 400 → 800 → 1.6 s, capped at 2 s) with up to 10 attempts (~11 s total worst case). - Surfaces a clean error message (parsed from the JSON envelope) when retries are exhausted. Changelog: [Internal] Reviewed By: andrewdacenko Differential Revision: D101791796 --- .../react-native-fantom/runner/bundling.js | 134 ++++++++++++------ 1 file changed, 93 insertions(+), 41 deletions(-) diff --git a/private/react-native-fantom/runner/bundling.js b/private/react-native-fantom/runner/bundling.js index ddaf839581d2..f58bbdfb069f 100644 --- a/private/react-native-fantom/runner/bundling.js +++ b/private/react-native-fantom/runner/bundling.js @@ -25,64 +25,116 @@ type BundleOptions = { const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); export async function createBundle(options: BundleOptions): Promise { - let lastBundleResult; - let lastBundleError; - const bundleURL = getBundleURL(options); + const response = await fetchBundleWithRetry(bundleURL); - // Retry in case Metro hasn't seen the changes in the filesystem yet. - // TODO(T231910841): Remove this when Metro fixes consistency issues when resolving HTTP requests. - let attemps = 0; - do { - if (attemps > 0) { - await sleep(500); - } + await fs.promises.writeFile(options.out, await response.text(), 'utf8'); - lastBundleError = null; - lastBundleResult = null; + // Each test uses a unique entrypoint, so the bundle graph will never be + // requested again. Send DELETE to evict Metro's cached dependency graph + // and delta calculator for this bundle, freeing the memory. + try { + await fetch(bundleURL, {method: 'DELETE'}); + } catch { + // Best-effort cleanup — don't fail the test if eviction fails. + } +} +// Metro's file watcher can take a moment to observe a freshly written +// entrypoint (especially on Linux, where metro-file-map's FallbackWatcher +// debounces fs events by 100 ms). Until Metro fixes the consistency issue +// between HTTP requests and the file map (see TODO below), we retry on +// errors that look like the entry — or one of its transitive deps — has +// not been picked up yet: +// - HTTP 404: returned when Metro can't resolve the entry file path +// itself (`UnableToResolveError` thrown from `_resolveRelativePath`). +// - HTTP 500 with `type: 'UnableToResolveError'`: a deeper require could +// not be resolved while building the dependency graph. +// - HTTP 500 with `type: 'ResourceNotFoundError'`: the entry was found +// and then went missing (rare, but we treat it the same way). +// - fetch network errors: brief connectivity issue. +// All other failures (syntax errors, transform errors, etc.) are real and +// thrown immediately so we don't waste time retrying them. +// +// TODO(T231910841): Remove this when Metro fixes consistency issues when +// resolving HTTP requests. +const MAX_BUNDLE_FETCH_ATTEMPTS = 10; +const BUNDLE_FETCH_BASE_BACKOFF_MS = 100; +const BUNDLE_FETCH_MAX_BACKOFF_MS = 2_000; + +async function fetchBundleWithRetry(bundleURL: URL): Promise { + let lastError: ?Error; + let lastErrorMessage = ''; + + for (let attempt = 0; attempt < MAX_BUNDLE_FETCH_ATTEMPTS; attempt++) { + if (attempt > 0) { + const backoff = Math.min( + BUNDLE_FETCH_BASE_BACKOFF_MS * 2 ** (attempt - 1), + BUNDLE_FETCH_MAX_BACKOFF_MS, + ); + await sleep(backoff); + } + + let response; try { - lastBundleResult = await fetch(bundleURL); - } catch (e) { - lastBundleError = e; + response = await fetch(bundleURL); + } catch (error: unknown) { + lastError = + error instanceof Error + ? error + : new Error(typeof error === 'string' ? error : String(error)); + lastErrorMessage = lastError.message; + continue; } - attemps++; - } while ( - attemps < 3 && - (lastBundleError || lastBundleResult?.status === 404) - ); + if (response.ok) { + return response; + } - if (lastBundleError || lastBundleResult?.ok !== true) { - let errorMessage = - lastBundleError?.message ?? (await lastBundleResult?.text()) ?? ''; + const bodyText = await response.text(); + const {message, retryable} = parseMetroErrorBody(response.status, bodyText); + lastErrorMessage = message; - try { - const parsed = JSON.parse(errorMessage); - if (typeof parsed.message === 'string') { - errorMessage = parsed.message; - } - } catch { - // Not JSON — use the raw text as-is. + if (!retryable) { + throw new Error(`Failed to request bundle from Metro:\n${message}`); } - - throw new Error(`Failed to request bundle from Metro:\n${errorMessage}`); } - await fs.promises.writeFile( - options.out, - await lastBundleResult.text(), - 'utf8', + throw new Error( + `Failed to request bundle from Metro after ${MAX_BUNDLE_FETCH_ATTEMPTS} attempts:\n${lastErrorMessage}`, ); +} + +function parseMetroErrorBody( + status: number, + bodyText: string, +): {message: string, retryable: boolean} { + let message = bodyText; + let errorType: ?string; - // Each test uses a unique entrypoint, so the bundle graph will never be - // requested again. Send DELETE to evict Metro's cached dependency graph - // and delta calculator for this bundle, freeing the memory. try { - await fetch(bundleURL, {method: 'DELETE'}); + const parsed = JSON.parse(bodyText); + if (typeof parsed?.message === 'string') { + message = parsed.message; + } + if (typeof parsed?.type === 'string') { + errorType = parsed.type; + } } catch { - // Best-effort cleanup — don't fail the test if eviction fails. + // Not JSON — keep the raw body as the message. } + + // 404 is returned by Metro when the entry file path can't be resolved. + // 500 with `UnableToResolveError`/`ResourceNotFoundError` signals that + // either the entry or a transitive dep wasn't seen by the file watcher + // yet — both should resolve themselves once Metro's file map catches up. + const retryable = + status === 404 || + (status === 500 && + (errorType === 'UnableToResolveError' || + errorType === 'ResourceNotFoundError')); + + return {message, retryable}; } export async function createSourceMap(options: BundleOptions): Promise {