Skip to content

Commit 58c6d30

Browse files
authored
[browser][coreCLR] Enable download retry by default and improve retry sequencing (#127559)
1 parent 9f657f0 commit 58c6d30

5 files changed

Lines changed: 64 additions & 10 deletions

File tree

src/mono/wasm/Wasm.Build.Tests/ModuleConfigTests.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public ModuleConfigTests(ITestOutputHelper output, SharedBuildPerTestClassFixtur
2323

2424
[Theory]
2525
[InlineData(false)]
26-
// [InlineData(true)] // ActiveIssue: https://github.com/dotnet/runtime/issues/124946
26+
[InlineData(true)]
2727
public async Task DownloadProgressFinishes(bool failAssemblyDownload)
2828
{
2929
Configuration config = Configuration.Debug;
@@ -50,13 +50,39 @@ public async Task DownloadProgressFinishes(bool failAssemblyDownload)
5050
"The download progress test did emit unexpected message about second download retry"
5151
);
5252
Assert.True(
53-
result.TestOutput.Any(m => m.Contains("Throw error instead of downloading resource") == failAssemblyDownload),
53+
result.TestOutput.Any(m => m.Contains("Throw error instead of downloading resource")) == failAssemblyDownload,
5454
failAssemblyDownload
5555
? "The download progress test didn't emit expected message about failing download"
5656
: "The download progress test did emit unexpected message about failing download"
5757
);
5858
}
5959

60+
[Fact]
61+
public async Task DownloadRetryRecoversFromFailure()
62+
{
63+
Configuration config = Configuration.Release;
64+
ProjectInfo info = CopyTestAsset(config, false, TestAsset.WasmBasicTestApp, "ModuleConfigTests_DownloadRetryRecoversFromFailure");
65+
PublishProject(info, config);
66+
67+
var result = await RunForPublishWithWebServer(new BrowserRunOptions(
68+
Configuration: config,
69+
TestScenario: "DownloadResourceProgressTest",
70+
BrowserQueryString: new NameValueCollection { {"failAssemblyDownload", "true" } }
71+
));
72+
Assert.True(
73+
result.TestOutput.Any(m => m.Contains("DownloadResourceProgress: Finished")),
74+
"Download progress didn't finish after retries"
75+
);
76+
Assert.True(
77+
result.ConsoleOutput.Any(m => m.Contains("Retrying download")),
78+
"Expected retry log message was not emitted"
79+
);
80+
Assert.False(
81+
result.ConsoleOutput.Any(m => m.Contains("Retrying download (2)")),
82+
"Second retry should not be needed since first retry succeeds"
83+
);
84+
}
85+
6086
[Fact, TestCategory("bundler-friendly")]
6187
public async Task OutErrOverrideWorks()
6288
{

src/native/libs/Common/JavaScript/loader/assets.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ let totalAssetsToDownload = 0;
1717
let loadBootResourceCallback: LoadBootResourceCallback | undefined = undefined;
1818
const loadedLazyAssemblies = new Set<string>();
1919
let mainWasmAsset: WasmAsset | null = null;
20+
const allDownloadsQueuedPCS = createPromiseCompletionSource<void>();
21+
22+
export function resolveAllDownloadsQueued(): void {
23+
allDownloadsQueuedPCS.resolve();
24+
}
2025

2126
export function setLoadBootResourceCallback(callback: LoadBootResourceCallback | undefined): void {
2227
loadBootResourceCallback = callback;
@@ -332,7 +337,14 @@ export async function fetchNativeSymbols(asset: SymbolsAsset): Promise<void> {
332337

333338
async function fetchBytes(asset: AssetEntryInternal): Promise<Uint8Array | null> {
334339
dotnetAssert.check(asset && asset.resolvedUrl, "Bad asset.resolvedUrl");
335-
const response = await loadResource(asset);
340+
let response: Response;
341+
try {
342+
response = await loadResource(asset);
343+
} catch (err: any) {
344+
// Strip .silent flag from download errors so they are properly reported via exit listeners
345+
const message = err instanceof Error ? err.message : String(err);
346+
throw new Error(`Failed to load resource '${asset.name}' from '${asset.resolvedUrl}': ${message}`, { cause: err });
347+
}
336348
if (!response.ok) {
337349
if (asset.isOptional) {
338350
dotnetLogger.warn(`Optional resource '${asset.name}' failed to load from '${asset.resolvedUrl}'. HTTP status: ${response.status} ${response.statusText}`);
@@ -346,7 +358,14 @@ async function fetchBytes(asset: AssetEntryInternal): Promise<Uint8Array | null>
346358

347359
async function fetchText(asset: AssetEntryInternal): Promise<string | null> {
348360
dotnetAssert.check(asset && asset.resolvedUrl, "Bad asset.resolvedUrl");
349-
const response = await loadResource(asset);
361+
let response: Response;
362+
try {
363+
response = await loadResource(asset);
364+
} catch (err: any) {
365+
// Strip .silent flag from download errors so they are properly reported via exit listeners
366+
const message = err instanceof Error ? err.message : String(err);
367+
throw new Error(`Failed to load resource '${asset.name}' from '${asset.resolvedUrl}': ${message}`, { cause: err });
368+
}
350369
if (!response.ok) {
351370
if (asset.isOptional) {
352371
dotnetLogger.warn(`Optional resource '${asset.name}' failed to load from '${asset.resolvedUrl}'. HTTP status: ${response.status} ${response.statusText}`);
@@ -377,15 +396,19 @@ async function loadResourceRetry(asset: AssetEntryInternal): Promise<Response> {
377396
if (response.ok || asset.isOptional || noRetryStatusCodes.has(response.status)) {
378397
return response;
379398
}
399+
// second attempt only after all first attempts are queued
400+
await allDownloadsQueuedPCS.promise;
380401
if (response.status === 429) {
381402
// Too Many Requests
382403
await delay(100);
383404
}
405+
dotnetLogger.debug(`Retrying download '${asset.name}'`);
384406
response = await loadResourceAttempt();
385407
if (response.ok || noRetryStatusCodes.has(response.status)) {
386408
return response;
387409
}
388410
await delay(100); // wait 100ms before the last retry
411+
dotnetLogger.debug(`Retrying download (2) '${asset.name}' after delay`);
389412
response = await loadResourceAttempt();
390413
if (response.ok) {
391414
return response;

src/native/libs/Common/JavaScript/loader/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ function defaultConfig(target: LoaderConfigInternal) {
9292
if (target.diagnosticTracing === undefined) target.diagnosticTracing = false;
9393
if (target.virtualWorkingDirectory === undefined) target.virtualWorkingDirectory = browserVirtualAppBase;
9494
if (target.maxParallelDownloads === undefined) target.maxParallelDownloads = 16;
95+
if (target.enableDownloadRetry === undefined) target.enableDownloadRetry = true;
9596
normalizeConfig(target);
9697
}
9798

src/native/libs/Common/JavaScript/loader/lib-initializers.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@ async function invokeWithErrorHandling(
3333
} catch (err) {
3434
const name = asset.name || asset.resolvedUrl || "unknown";
3535
const message = err instanceof Error ? err.message : String(err);
36-
dotnetLogger.warn(
37-
`Failed to invoke '${functionName}' on library initializer '${name}': ${message}`
38-
);
39-
exit(1, err);
40-
throw err;
36+
const wrappedError = new Error(`Failed to invoke '${functionName}' on library initializer '${name}': ${message}`, { cause: err });
37+
dotnetLogger.warn(wrappedError.message);
38+
exit(1, wrappedError);
39+
throw wrappedError;
4140
}
4241
}

src/native/libs/Common/JavaScript/loader/run.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { exit, runtimeState } from "./exit";
88
import { createPromiseCompletionSource } from "./promise-completion-source";
99
import { getIcuResourceName } from "./icu";
1010
import { loaderConfig, validateLoaderConfig } from "./config";
11-
import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks } from "./assets";
11+
import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks, resolveAllDownloadsQueued } from "./assets";
1212
import { initPolyfills } from "./polyfills";
1313
import { validateEngineFeatures } from "./bootstrap";
1414

@@ -131,6 +131,11 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea
131131
const isDebuggingSupported = loaderConfig.debugLevel != 0;
132132
const corePDBsPromise = forEachResource(resources.corePdb, fetchPdb, () => isDebuggingSupported);
133133
const pdbsPromise = forEachResource(resources.pdb, fetchPdb, () => isDebuggingSupported);
134+
135+
// Signal that all first-attempt asset fetches queued above via forEachResource/fetch* have been queued.
136+
// Retry logic waits on this before attempting second downloads for those asset fetches.
137+
resolveAllDownloadsQueued();
138+
134139
// In download-only mode, just add prefetch hints for runtime-ready modules so create() loads them from cache.
135140
// In create mode, load them now so onRuntimeReady can be called later.
136141
let modulesAfterRuntimeReadyPromises: [JsAsset, Promise<any>][] = [];

0 commit comments

Comments
 (0)