From 08a41c2e10afaa7fa38dd1019807abc73e85f86f Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Wed, 3 Jun 2026 10:15:55 -0400 Subject: [PATCH] CLF-52 Harden chunked stat loader fallback --- .../src/main/resources/META-INF/invert.js | 97 ++++++++++++++++--- invert-report/src/jsMain/kotlin/External.kt | 6 +- .../invert/common/CollectedDataRepo.kt | 23 +++++ .../squareup/invert/common/InvertReport.kt | 17 +++- .../squareup/invert/common/ReportDataRepo.kt | 4 + .../common/pages/StatDetailReportPage.kt | 8 +- .../navigation/RemoteJsLoadingProgress.kt | 66 ++++++++++--- 7 files changed, 190 insertions(+), 31 deletions(-) diff --git a/invert-gradle-plugin/src/main/resources/META-INF/invert.js b/invert-gradle-plugin/src/main/resources/META-INF/invert.js index 072552b8..da54432d 100644 --- a/invert-gradle-plugin/src/main/resources/META-INF/invert.js +++ b/invert-gradle-plugin/src/main/resources/META-INF/invert.js @@ -70,24 +70,89 @@ window.externalLoadJavaScriptFile = function (key, callback) { /** * Load a stat key via chunked JSON transport when a manifest is available. - * Falls back to legacy externalLoadJavaScriptFile if no manifest exists. + * Falls back to legacy externalLoadJavaScriptFile only when no manifest exists. * * Flow: fetch manifest → fetch all chunks in parallel → merge statsByModule → callback(json) */ -window.externalLoadChunkedJsonFile = function (key, callback) { +window.externalLoadChunkedJsonFile = function (key, callback, errorCallback) { + function chunkTransportUnavailable(message) { + var err = new Error(message); + err.noChunkManifest = true; + return err; + } + + function fetchWithTimeout(url, timeoutMs, responseHandler) { + var controller = typeof AbortController === "function" ? new AbortController() : null; + var timeoutId; + var timeout = new Promise(function (_, reject) { + timeoutId = setTimeout(function () { + if (controller) { + controller.abort(); + } + reject(new Error("Timed out fetching " + url)); + }, timeoutMs); + }); + var request = Promise.resolve().then(function () { + return controller ? fetch(url, { signal: controller.signal }) : fetch(url); + }).then(function (response) { + return responseHandler ? responseHandler(response) : response; + }); + return Promise.race([request, timeout]).then( + function (response) { + clearTimeout(timeoutId); + return response; + }, + function (err) { + clearTimeout(timeoutId); + throw err; + } + ); + } + + function fetchJson(url, attemptsRemaining) { + return fetchWithTimeout( + url, + 30000, + function (response) { + if (!response.ok) throw new Error("Failed to fetch " + url + " (" + response.status + ")"); + return response.json(); + } + ) + .catch(function (err) { + if (attemptsRemaining > 1) { + return fetchJson(url, attemptsRemaining - 1); + } + throw err; + }); + } + var manifestUrl = "js/" + key + ".manifest.json"; - fetch(manifestUrl) - .then(function (response) { - if (!response.ok) throw new Error("No manifest for " + key); - return response.json(); + fetchWithTimeout( + manifestUrl, + 30000, + function (response) { + if (!response.ok) { + throw chunkTransportUnavailable("Manifest unavailable for " + key + " (" + response.status + ")"); + } + return response.json().catch(function (err) { + err.invalidChunkManifest = true; + throw err; + }); + } + ) + .catch(function (err) { + if (err.noChunkManifest || err.invalidChunkManifest) { + throw err; + } + throw chunkTransportUnavailable("Unable to fetch manifest for " + key + ": " + err.message); }) .then(function (manifest) { + if (!manifest.chunkFiles || manifest.chunkFiles.length === 0) { + throw new Error("Chunk manifest for " + key + " does not list any chunk files"); + } return Promise.all( manifest.chunkFiles.map(function (chunkFile) { - return fetch("js/" + chunkFile).then(function (r) { - if (!r.ok) throw new Error("Failed to fetch chunk: " + chunkFile); - return r.json(); - }); + return fetchJson("js/" + chunkFile, 3); }) ); }) @@ -103,8 +168,16 @@ window.externalLoadChunkedJsonFile = function (key, callback) { callback(JSON.stringify(merged)); }) .catch(function (err) { - console.log("Chunked load unavailable for " + key + ", falling back to legacy: " + err.message); - externalLoadJavaScriptFile(key, callback); + if (err.noChunkManifest) { + console.log("Chunked load unavailable for " + key + ", falling back to legacy: " + err.message); + externalLoadJavaScriptFile(key, callback); + return; + } + var errorMessage = "Chunked load failed for " + key + ": " + err.message; + console.error(errorMessage); + if (typeof errorCallback === "function") { + errorCallback(errorMessage); + } }); } diff --git a/invert-report/src/jsMain/kotlin/External.kt b/invert-report/src/jsMain/kotlin/External.kt index 85ec658f..2dbced91 100644 --- a/invert-report/src/jsMain/kotlin/External.kt +++ b/invert-report/src/jsMain/kotlin/External.kt @@ -2,7 +2,11 @@ external fun externalLoadJavaScriptFile(key: String, callback: (json: String) -> Unit) -external fun externalLoadChunkedJsonFile(key: String, callback: (json: String) -> Unit) +external fun externalLoadChunkedJsonFile( + key: String, + callback: (json: String) -> Unit, + errorCallback: (message: String) -> Unit, +) external fun loadJsFileAsync(url: String, callback: () -> Unit) diff --git a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/CollectedDataRepo.kt b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/CollectedDataRepo.kt index 84a959a3..94afd1a2 100644 --- a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/CollectedDataRepo.kt +++ b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/CollectedDataRepo.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext /** @@ -32,6 +33,7 @@ class CollectedDataRepo( ) { private val hasLoadedFile = mutableMapOf() + private val _fileLoadFailures = MutableStateFlow>(mapOf()) private suspend fun loadJsOfType(fileKey: FileKey) = withContext(coroutineDispatcher) { if (!hasLoadedFile.contains(fileKey)) { @@ -44,6 +46,27 @@ class CollectedDataRepo( loadJsOfType(jsReportFileKey.key) } + fun fileLoadFailure(fileKey: FileKey): Flow = + _fileLoadFailures.map { it[fileKey] } + + fun fileLoadFailed(fileKey: FileKey, message: String) { + hasLoadedFile.remove(fileKey) + _fileLoadFailures.update { + it.toMutableMap().apply { + put(fileKey, message) + } + } + } + + fun fileLoadSucceeded(fileKey: FileKey) { + hasLoadedFile[fileKey] = true + _fileLoadFailures.update { + it.toMutableMap().apply { + remove(fileKey) + } + } + } + private val _collectedPluginInfoReport: MutableStateFlow = MutableStateFlow(null) val collectedPluginInfoReport: Flow = _collectedPluginInfoReport diff --git a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/InvertReport.kt b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/InvertReport.kt index c0eab86e..3e5ab5da 100644 --- a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/InvertReport.kt +++ b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/InvertReport.kt @@ -66,8 +66,21 @@ class InvertReport( val collectedDataRepo = CollectedDataRepo( coroutineDispatcher = Dispatchers.Default, loadFileData = { jsFileKey, reportRepoData -> - RemoteJsLoadingProgress.loadJavaScriptFile(jsFileKey) { json -> - RemoteJsLoadingProgress.handleLoadedJsFile(reportRepoData, jsFileKey, json) + RemoteJsLoadingProgress.loadJavaScriptFile( + fileKey = jsFileKey, + onFailure = { message -> + reportRepoData.fileLoadFailed(jsFileKey, message) + } + ) { json -> + try { + RemoteJsLoadingProgress.handleLoadedJsFile(reportRepoData, jsFileKey, json) + reportRepoData.fileLoadSucceeded(jsFileKey) + } catch (throwable: Throwable) { + reportRepoData.fileLoadFailed( + fileKey = jsFileKey, + message = throwable.message ?: throwable.toString() + ) + } } }, ) diff --git a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/ReportDataRepo.kt b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/ReportDataRepo.kt index 3edfa39a..95c187a8 100644 --- a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/ReportDataRepo.kt +++ b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/ReportDataRepo.kt @@ -14,6 +14,7 @@ import com.squareup.invert.models.StatKey import com.squareup.invert.models.StatMetadata import com.squareup.invert.models.js.CollectedStatTotalsJsReportModel import com.squareup.invert.models.js.HistoricalData +import com.squareup.invert.models.js.JsReportFileKey import com.squareup.invert.models.js.MetadataJsReportModel import com.squareup.invert.models.js.PluginsJsReportModel import com.squareup.invert.models.js.StatJsReportModel @@ -103,6 +104,9 @@ class ReportDataRepo( fun statForKey(statKey: StatKey): Flow = collectedDataRepo.statData(statKey).mapLatest { it?.get(statKey) } + fun statLoadFailure(statKey: StatKey): Flow = + collectedDataRepo.fileLoadFailure(JsReportFileKey.STAT.key + "_$statKey") + val pluginIdToAllModulesMap: Flow>?> = collectedDataRepo.collectedPluginInfoReport .mapLatest { diff --git a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/pages/StatDetailReportPage.kt b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/pages/StatDetailReportPage.kt index 4a937566..cd8f2f65 100644 --- a/invert-report/src/jsMain/kotlin/com/squareup/invert/common/pages/StatDetailReportPage.kt +++ b/invert-report/src/jsMain/kotlin/com/squareup/invert/common/pages/StatDetailReportPage.kt @@ -113,8 +113,15 @@ fun StatDetailComposable( val metadata by reportDataRepo.reportMetadata.collectAsState(null) val statKey = statsNavRoute.statKey + val statLoadFailure: String? by reportDataRepo.statLoadFailure(statKey).collectAsState(null) val statsData: StatJsReportModel? by reportDataRepo.statForKey(statKey).collectAsState(null) + statLoadFailure?.let { message -> + H3 { Text("Failed to load stat data") } + Text(message) + return + } + if (moduleToOwnerMapFlowValue == null || metadata == null || statsData == null) { BootstrapLoadingSpinner() return @@ -274,4 +281,3 @@ fun statToDetailsString(stat: Stat?): String = when (stat) { else -> "" } ?: "" - diff --git a/invert-report/src/jsMain/kotlin/navigation/RemoteJsLoadingProgress.kt b/invert-report/src/jsMain/kotlin/navigation/RemoteJsLoadingProgress.kt index ee775d92..9495932b 100644 --- a/invert-report/src/jsMain/kotlin/navigation/RemoteJsLoadingProgress.kt +++ b/invert-report/src/jsMain/kotlin/navigation/RemoteJsLoadingProgress.kt @@ -32,27 +32,63 @@ object RemoteJsLoadingProgress { } } - fun loadJavaScriptFile(fileKey: String, callback: (String) -> Unit) { + fun loadJavaScriptFile( + fileKey: String, + onFailure: (String) -> Unit = {}, + callback: (String) -> Unit + ) { if (!awaitingResults.value.contains(fileKey)) { awaitingResults.value = awaitingResults.value.toMutableList().apply { add(fileKey) } Log.d("Loading $fileKey") - val loadingTimeout = window.setTimeout( - { - Log.d("Timeout while fetching $fileKey data.") + var loadingTimeout = 0 + var completed = false + fun complete(block: () -> Unit) { + if (completed) return + completed = true + try { + block() + } finally { + window.clearTimeout(loadingTimeout) awaitingResults.value = awaitingResults.value.toMutableList().apply { remove(fileKey) } + } + } + val onLoaded: (String) -> Unit = { json -> + complete { + Log.d("Finished Loading $fileKey") + callback(json) + } + } + val onFailed: (String) -> Unit = { message -> + complete { + Log.d("Failed Loading $fileKey: $message") + onFailure(message) + } + } + fun failureMessage(throwable: Throwable): String = + throwable.message ?: throwable.toString() + + loadingTimeout = window.setTimeout( + { + onFailed("Timed out fetching $fileKey data.") }, getTimeoutForFileKey(fileKey) ) - val onLoaded: (String) -> Unit = { json -> - Log.d("Finished Loading $fileKey") - window.clearTimeout(loadingTimeout) - callback(json) - awaitingResults.value = awaitingResults.value.toMutableList().apply { remove(fileKey) } - } - if (fileKey.startsWith("stat_")) { - externalLoadChunkedJsonFile(fileKey, onLoaded) - } else { - externalLoadJavaScriptFile(fileKey, onLoaded) + try { + if (fileKey.startsWith("stat_")) { + externalLoadChunkedJsonFile(fileKey, onLoaded, onFailed) + } else { + externalLoadJavaScriptFile(fileKey, onLoaded) + } + } catch (throwable: Throwable) { + if (fileKey.startsWith("stat_")) { + try { + externalLoadJavaScriptFile(fileKey, onLoaded) + } catch (legacyThrowable: Throwable) { + onFailed(failureMessage(legacyThrowable)) + } + } else { + onFailed(failureMessage(throwable)) + } } } } @@ -123,4 +159,4 @@ object RemoteJsLoadingProgress { } } } -} \ No newline at end of file +}