diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js index 2a24dbff8c5..adb6b3d372f 100644 --- a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -11,6 +11,7 @@ 'use strict'; const { + download, exists, extractXCFramework, findFirst, @@ -545,6 +546,125 @@ describe('exists', () => { }); }); +// --------------------------------------------------------------------------- +// download — retries transient transport failures without masking client errors. +// --------------------------------------------------------------------------- + +describe('download', () => { + let originalFetch; + let tempDir; + let warnSpy; + + beforeEach(() => { + originalFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-download-')); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + warnSpy.mockRestore(); + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function downloadResponse(body, error, stall = false) { + const bytes = Buffer.from(body); + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: name => + name.toLowerCase() === 'content-length' ? String(bytes.length) : null, + }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + if (error != null) { + controller.error(error); + } else if (!stall) { + controller.close(); + } + }, + }), + }; + } + + it('retries a transient stream failure and replaces the partial download', async () => { + globalThis.fetch = jest + .fn() + .mockResolvedValueOnce( + downloadResponse('partial', new Error('terminated')), + ) + .mockResolvedValueOnce(downloadResponse('complete')); + const destination = path.join(tempDir, 'artifact.tar.gz'); + + const pending = download( + 'https://example/artifact.tar.gz', + destination, + () => {}, + ); + await jest.advanceTimersByTimeAsync(1_500); + await pending; + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('retrying in 1000ms'), + ); + expect(fs.readFileSync(destination, 'utf8')).toBe('complete'); + expect(fs.existsSync(`${destination}.download`)).toBe(false); + }); + + it('retries when an otherwise healthy response stops making progress', async () => { + jest.useFakeTimers(); + try { + globalThis.fetch = jest + .fn() + .mockResolvedValueOnce(downloadResponse('partial', null, true)) + .mockResolvedValueOnce(downloadResponse('complete')); + const destination = path.join(tempDir, 'stalled.tar.gz'); + + const pending = download( + 'https://example/stalled.tar.gz', + destination, + () => {}, + ); + await jest.advanceTimersByTimeAsync(61_500); + await pending; + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('retrying in 1000ms'), + ); + expect(fs.readFileSync(destination, 'utf8')).toBe('complete'); + expect(fs.existsSync(`${destination}.download`)).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('does not retry a deterministic HTTP client error', async () => { + globalThis.fetch = jest.fn(async () => ({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: {get: () => null}, + body: null, + })); + const destination = path.join(tempDir, 'missing.tar.gz'); + + await expect( + download('https://example/missing.tar.gz', destination, () => {}), + ).rejects.toThrow(/HTTP 404/); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + expect(fs.existsSync(destination)).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // resolveSnapshotUrl — parses maven-metadata.xml into a timestamped URL. // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 82cb1dfc4ea..df176372db3 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -640,16 +640,26 @@ function createProgressDisplay( type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void; */ -async function download( +const DOWNLOAD_MAX_ATTEMPTS = 3; +const DOWNLOAD_RETRY_BASE_DELAY_MS = 1000; +const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000; + +function isRetryableDownloadError(error /*: Error */) /*: boolean */ { + // Client errors are deterministic except for request timeout / rate limiting. + // Network and stream errors do not carry an HTTP prefix and are safe to retry. + const httpStatus = /^HTTP (\d{3})\b/.exec(error.message)?.[1]; + if (httpStatus == null) { + return true; + } + const status = Number(httpStatus); + return status === 408 || status === 429 || status >= 500; +} + +async function downloadOnce( url /*: string */, destPath /*: string */, onProgress /*:: ?: ProgressCallback */, ) /*: Promise */ { - if (fs.existsSync(destPath)) { - log(` Already cached: ${path.basename(destPath)}`); - return; - } - const tmpPath = destPath + '.download'; try { // $FlowFixMe[incompatible-call] global fetch not in Flow stubs @@ -662,6 +672,7 @@ async function download( let downloadedBytes = 0; let lastPrintTime = Date.now(); let lastPrintBytes = 0; + let lastDataTime = Date.now(); const startTime = Date.now(); const fileStream = fs.createWriteStream(tmpPath); @@ -719,14 +730,25 @@ async function download( await new Promise((resolve, reject) => { let progressInterval; try { - progressInterval = setInterval(() => reportProgress(), 500); + progressInterval = setInterval(() => { + reportProgress(); + if (Date.now() - lastDataTime >= DOWNLOAD_IDLE_TIMEOUT_MS) { + nodeReadable.destroy( + new Error( + `Download made no progress for ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000}s: ${url}`, + ), + ); + } + }, 500); nodeReadable .on('data', chunk => { downloadedBytes += chunk.length; + lastDataTime = Date.now(); }) .on('error', err => { clearInterval(progressInterval); + fileStream.destroy(); reject(err); }) .pipe(fileStream) @@ -737,10 +759,13 @@ async function download( }) .on('error', err => { clearInterval(progressInterval); + nodeReadable.destroy(); reject(err); }); } catch (err) { if (progressInterval != null) clearInterval(progressInterval); + nodeReadable.destroy(); + fileStream.destroy(); reject(err); } }); @@ -753,7 +778,7 @@ async function download( fs.renameSync(tmpPath, destPath); } catch (err) { - // Clean up partial .download temp file on failure + // Clean up partial .download temp file on failure. try { fs.unlinkSync(tmpPath); } catch { @@ -763,6 +788,37 @@ async function download( } } +async function download( + url /*: string */, + destPath /*: string */, + onProgress /*:: ?: ProgressCallback */, +) /*: Promise */ { + if (fs.existsSync(destPath)) { + log(` Already cached: ${path.basename(destPath)}`); + return; + } + + for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) { + try { + await downloadOnce(url, destPath, onProgress); + return; + } catch (error) { + if ( + attempt === DOWNLOAD_MAX_ATTEMPTS || + !isRetryableDownloadError(error) + ) { + throw error; + } + const delayMs = DOWNLOAD_RETRY_BASE_DELAY_MS * attempt; + warn( + ` Download failed (${error.message}); retrying in ${delayMs}ms ` + + `(attempt ${attempt + 1}/${DOWNLOAD_MAX_ATTEMPTS})`, + ); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } +} + /** * Extracts a .tar.gz and returns the path to the first .xcframework found. */ @@ -1544,6 +1600,7 @@ module.exports = { resolveLatestV1Version, resolveRNCoreArtifact, resolveRNDepsArtifact, + download, exists, formatBytes, formatSpeed,