From 2d1bc229098341ec95f516446a97fca3692ef178 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 10 Sep 2026 00:51:19 +0200 Subject: [PATCH] frontend: ignore stale local-match refresh responses after workflow switch --- tests/test_downloads_tab_workflow_route.mjs | 71 +++++++++++++++++++ .../actions/resolve_download_methods.js | 24 ++++--- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 083dd63..a69ba48 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -6265,6 +6265,77 @@ test('download snapshots with identical model keys remain separated by workflow' ); }); +function createLocalRefreshRaceDialog() { + const dialog = { + scope: 'workflow-a', + missingModels: [], + getCurrentDownloadWorkflowScopeIdentity() { return this.scope; }, + getMissingModelKey(missing) { return missing.key; }, + getDownloadedLocalMatchTarget(missing, filename) { return filename; }, + getMissingModelDomKey(missing) { return missing.key; }, + getWorkflowScopedQueueKey() { return this.scope; }, + mergeLocalMatches(previous, next) { return [...next, ...previous]; }, + refreshLocalMatchesUiForMissing(missing) { this.rendered = missing; } + }; + for (const name of [ + 'getDownloadWorkflowScopeIdentity', 'getDownloadMissingIdentity', + 'getDownloadStateKey', 'getDownloadProgressStore', 'cloneLocalMatches', + 'rememberDownloadSnapshotForMissing', 'rememberDownloadedLocalMatchesForMissing', + 'persistLocalMatchesInAnalysisCache', 'restoreDownloadedLocalMatchesForMissing', + 'getLocalMatchRefreshKey', 'beginLocalMatchRefresh', 'isCurrentLocalMatchRefresh', + 'finishLocalMatchRefresh', 'refreshLocalMatchesForDownloadedMissing' + ]) { + dialog[name] = eval(`(${extractMethod(resolveDownloadMethodsSource, name)})`); + } + return dialog; +} + +test('completed local matches survive a later workflow analysis', () => { + const dialog = createLocalRefreshRaceDialog(); + const missing = { key: 'krea2', matches: [] }; + const exact = { confidence: 100 }; + dialog.rememberDownloadedLocalMatchesForMissing(missing, [exact]); + const replacement = { key: 'krea2', matches: [] }; + dialog.restoreDownloadedLocalMatchesForMissing(replacement, dialog.scope); + assert.deepEqual(replacement.matches, [exact]); + dialog.scope = 'workflow-b'; + const unrelated = { key: 'krea2', matches: [] }; + dialog.restoreDownloadedLocalMatchesForMissing(unrelated, dialog.scope); + assert.deepEqual(unrelated.matches, []); +}); + +test('download local refresh updates the model replaced while scanning', async () => { + const dialog = createLocalRefreshRaceDialog(); + const original = { key: 'krea2', matches: [{ confidence: 80 }] }; + dialog.missingModels = [original]; + let complete; + dialog.fetchLocalMatches = () => new Promise(resolve => { complete = resolve; }); + const pending = dialog.refreshLocalMatchesForDownloadedMissing(original, 'krea2.safetensors'); + const replacement = { key: 'krea2', matches: [{ confidence: 80 }] }; + dialog.missingModels = [replacement]; + complete({ matches: [{ confidence: 100 }] }); + await pending; + assert.equal(replacement.matches[0].confidence, 100); + assert.equal(dialog.rendered, replacement); +}); + +test('download local refresh ignores a response after switching workflows', async () => { + const dialog = createLocalRefreshRaceDialog(); + const original = { key: 'krea2', matches: [] }; + dialog.missingModels = [original]; + let complete; + dialog.fetchLocalMatches = () => new Promise(resolve => { complete = resolve; }); + const pending = dialog.refreshLocalMatchesForDownloadedMissing(original, 'krea2.safetensors'); + dialog.scope = 'workflow-b'; + const unrelated = { key: 'krea2', matches: [] }; + dialog.missingModels = [unrelated]; + complete({ matches: [{ confidence: 100 }] }); + assert.equal(await pending, null); + assert.deepEqual(unrelated.matches, []); + assert.equal(dialog.rendered, undefined); + assert.equal(dialog.downloadProgressByMissingKey, undefined); +}); + test('pending cancelling state cannot be overwritten by stale downloading progress', () => { const applyPendingDownloadStatus = eval(`(${extractMethod(resolveDownloadMethodsSource, 'applyPendingDownloadStatus')})`); const info = { diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 44c7d2a..89b73a9 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -450,9 +450,8 @@ export const resolveDownloadMethods = { getDownloadStateKey(missing, context = null) { const missingKey = this.getDownloadMissingIdentity(missing); - const workflowScope = context - ? this.getDownloadWorkflowScopeIdentity(context) - : this.getCurrentDownloadWorkflowScopeIdentity(); + const workflowScope = (context && this.getDownloadWorkflowScopeIdentity(context)) + || this.getCurrentDownloadWorkflowScopeIdentity(); return workflowScope ? `${workflowScope}::${missingKey}` : missingKey; }, @@ -1464,15 +1463,16 @@ export const resolveDownloadMethods = { nextDomChildren.forEach(child => container.appendChild(child)); }, - async refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv = null, category = '', downloadPath = '', downloadDirectory = '' } = {}) { + async refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv = null, category = '', downloadPath = '', downloadDirectory = '', workflowScope = null } = {}) { if (!missing) return []; + workflowScope ??= this.getCurrentDownloadWorkflowScopeIdentity(); + if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return null; const targetFilename = this.getDownloadedLocalMatchTarget(missing, downloadedFilename); if (!targetFilename) return []; const missingKey = this.getMissingModelKey(missing); const refreshToken = this.beginLocalMatchRefresh(missing); - const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing; try { if (progressDiv) { @@ -1492,8 +1492,11 @@ export const resolveDownloadMethods = { } const data = await this.fetchLocalMatches(targetFilename, category || missing.category || '', true); + if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return null; + // Linking can replace the model list while the scan is in flight. + const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing; if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { - return currentMissing.matches || []; + return null; } const matches = Array.isArray(data.matches) ? data.matches : []; @@ -1625,14 +1628,17 @@ export const resolveDownloadMethods = { } }, - async refreshAfterDownload(missing, downloadedFilename, { progressDiv = null, downloadBtn = null, category = '', downloadPath = '', downloadDirectory = '', alreadyExists = false } = {}) { + async refreshAfterDownload(missing, downloadedFilename, { progressDiv = null, downloadBtn = null, category = '', downloadPath = '', downloadDirectory = '', alreadyExists = false, workflowScope = null } = {}) { + workflowScope ??= this.getCurrentDownloadWorkflowScopeIdentity(); try { const matches = await this.refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv, category, downloadPath, - downloadDirectory + downloadDirectory, + workflowScope }); + if (matches === null || workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return; const downloadedLower = String(downloadedFilename || '').toLowerCase(); const perfectMatch = matches.find(match => { const matchFilename = match.filename || match.model?.filename || ''; @@ -1668,6 +1674,7 @@ export const resolveDownloadMethods = { ); } catch (error) { + if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return; console.error('Model Resolver: Error refreshing after download:', error); const snapshot = this.rememberDownloadSnapshotForMissing(missing, { downloadId: null, @@ -2218,6 +2225,7 @@ export const resolveDownloadMethods = { category, downloadPath: progress.path || info.downloadPath || '', downloadDirectory: progress.directory || info.downloadDirectory || '', + workflowScope: this.getDownloadWorkflowScopeIdentity(info), alreadyExists }); }, 500);