Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'use strict';

const {
download,
exists,
extractXCFramework,
findFirst,
Expand Down Expand Up @@ -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.
// ---------------------------------------------------------------------------
Expand Down
73 changes: 65 additions & 8 deletions packages/react-native/scripts/spm/download-spm-artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> */ {
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
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
});
Expand All @@ -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 {
Expand All @@ -763,6 +788,37 @@ async function download(
}
}

async function download(
url /*: string */,
destPath /*: string */,
onProgress /*:: ?: ProgressCallback */,
) /*: Promise<void> */ {
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.
*/
Expand Down Expand Up @@ -1544,6 +1600,7 @@ module.exports = {
resolveLatestV1Version,
resolveRNCoreArtifact,
resolveRNDepsArtifact,
download,
exists,
formatBytes,
formatSpeed,
Expand Down
Loading