Skip to content

Commit c1ffe9f

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Harden Metro bundle retry against file-watcher races (#56530)
Summary: Pull Request resolved: #56530 Each Fantom test writes a unique `\-<test>.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 fbshipit-source-id: 805ef9bc6e58fce7c1dc10d0cbbb7fdfa1fa24a5
1 parent 0f9b8e4 commit c1ffe9f

1 file changed

Lines changed: 93 additions & 41 deletions

File tree

private/react-native-fantom/runner/bundling.js

Lines changed: 93 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,64 +25,116 @@ type BundleOptions = {
2525
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
2626

2727
export async function createBundle(options: BundleOptions): Promise<void> {
28-
let lastBundleResult;
29-
let lastBundleError;
30-
3128
const bundleURL = getBundleURL(options);
29+
const response = await fetchBundleWithRetry(bundleURL);
3230

33-
// Retry in case Metro hasn't seen the changes in the filesystem yet.
34-
// TODO(T231910841): Remove this when Metro fixes consistency issues when resolving HTTP requests.
35-
let attemps = 0;
36-
do {
37-
if (attemps > 0) {
38-
await sleep(500);
39-
}
31+
await fs.promises.writeFile(options.out, await response.text(), 'utf8');
4032

41-
lastBundleError = null;
42-
lastBundleResult = null;
33+
// Each test uses a unique entrypoint, so the bundle graph will never be
34+
// requested again. Send DELETE to evict Metro's cached dependency graph
35+
// and delta calculator for this bundle, freeing the memory.
36+
try {
37+
await fetch(bundleURL, {method: 'DELETE'});
38+
} catch {
39+
// Best-effort cleanup — don't fail the test if eviction fails.
40+
}
41+
}
4342

43+
// Metro's file watcher can take a moment to observe a freshly written
44+
// entrypoint (especially on Linux, where metro-file-map's FallbackWatcher
45+
// debounces fs events by 100 ms). Until Metro fixes the consistency issue
46+
// between HTTP requests and the file map (see TODO below), we retry on
47+
// errors that look like the entry — or one of its transitive deps — has
48+
// not been picked up yet:
49+
// - HTTP 404: returned when Metro can't resolve the entry file path
50+
// itself (`UnableToResolveError` thrown from `_resolveRelativePath`).
51+
// - HTTP 500 with `type: 'UnableToResolveError'`: a deeper require could
52+
// not be resolved while building the dependency graph.
53+
// - HTTP 500 with `type: 'ResourceNotFoundError'`: the entry was found
54+
// and then went missing (rare, but we treat it the same way).
55+
// - fetch network errors: brief connectivity issue.
56+
// All other failures (syntax errors, transform errors, etc.) are real and
57+
// thrown immediately so we don't waste time retrying them.
58+
//
59+
// TODO(T231910841): Remove this when Metro fixes consistency issues when
60+
// resolving HTTP requests.
61+
const MAX_BUNDLE_FETCH_ATTEMPTS = 10;
62+
const BUNDLE_FETCH_BASE_BACKOFF_MS = 100;
63+
const BUNDLE_FETCH_MAX_BACKOFF_MS = 2_000;
64+
65+
async function fetchBundleWithRetry(bundleURL: URL): Promise<Response> {
66+
let lastError: ?Error;
67+
let lastErrorMessage = '';
68+
69+
for (let attempt = 0; attempt < MAX_BUNDLE_FETCH_ATTEMPTS; attempt++) {
70+
if (attempt > 0) {
71+
const backoff = Math.min(
72+
BUNDLE_FETCH_BASE_BACKOFF_MS * 2 ** (attempt - 1),
73+
BUNDLE_FETCH_MAX_BACKOFF_MS,
74+
);
75+
await sleep(backoff);
76+
}
77+
78+
let response;
4479
try {
45-
lastBundleResult = await fetch(bundleURL);
46-
} catch (e) {
47-
lastBundleError = e;
80+
response = await fetch(bundleURL);
81+
} catch (error: unknown) {
82+
lastError =
83+
error instanceof Error
84+
? error
85+
: new Error(typeof error === 'string' ? error : String(error));
86+
lastErrorMessage = lastError.message;
87+
continue;
4888
}
4989

50-
attemps++;
51-
} while (
52-
attemps < 3 &&
53-
(lastBundleError || lastBundleResult?.status === 404)
54-
);
90+
if (response.ok) {
91+
return response;
92+
}
5593

56-
if (lastBundleError || lastBundleResult?.ok !== true) {
57-
let errorMessage =
58-
lastBundleError?.message ?? (await lastBundleResult?.text()) ?? '';
94+
const bodyText = await response.text();
95+
const {message, retryable} = parseMetroErrorBody(response.status, bodyText);
96+
lastErrorMessage = message;
5997

60-
try {
61-
const parsed = JSON.parse(errorMessage);
62-
if (typeof parsed.message === 'string') {
63-
errorMessage = parsed.message;
64-
}
65-
} catch {
66-
// Not JSON — use the raw text as-is.
98+
if (!retryable) {
99+
throw new Error(`Failed to request bundle from Metro:\n${message}`);
67100
}
68-
69-
throw new Error(`Failed to request bundle from Metro:\n${errorMessage}`);
70101
}
71102

72-
await fs.promises.writeFile(
73-
options.out,
74-
await lastBundleResult.text(),
75-
'utf8',
103+
throw new Error(
104+
`Failed to request bundle from Metro after ${MAX_BUNDLE_FETCH_ATTEMPTS} attempts:\n${lastErrorMessage}`,
76105
);
106+
}
107+
108+
function parseMetroErrorBody(
109+
status: number,
110+
bodyText: string,
111+
): {message: string, retryable: boolean} {
112+
let message = bodyText;
113+
let errorType: ?string;
77114

78-
// Each test uses a unique entrypoint, so the bundle graph will never be
79-
// requested again. Send DELETE to evict Metro's cached dependency graph
80-
// and delta calculator for this bundle, freeing the memory.
81115
try {
82-
await fetch(bundleURL, {method: 'DELETE'});
116+
const parsed = JSON.parse(bodyText);
117+
if (typeof parsed?.message === 'string') {
118+
message = parsed.message;
119+
}
120+
if (typeof parsed?.type === 'string') {
121+
errorType = parsed.type;
122+
}
83123
} catch {
84-
// Best-effort cleanupdon't fail the test if eviction fails.
124+
// Not JSONkeep the raw body as the message.
85125
}
126+
127+
// 404 is returned by Metro when the entry file path can't be resolved.
128+
// 500 with `UnableToResolveError`/`ResourceNotFoundError` signals that
129+
// either the entry or a transitive dep wasn't seen by the file watcher
130+
// yet — both should resolve themselves once Metro's file map catches up.
131+
const retryable =
132+
status === 404 ||
133+
(status === 500 &&
134+
(errorType === 'UnableToResolveError' ||
135+
errorType === 'ResourceNotFoundError'));
136+
137+
return {message, retryable};
86138
}
87139

88140
export async function createSourceMap(options: BundleOptions): Promise<void> {

0 commit comments

Comments
 (0)