diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs index adea495..91c79c7 100644 --- a/tests/test_dom_patchers.mjs +++ b/tests/test_dom_patchers.mjs @@ -13,6 +13,10 @@ const queueMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/actions/queue_methods.js'), 'utf8' ); +const searchPanelMethodsSource = fs.readFileSync( + path.join(projectRoot, 'web/resolver/search/search_panel.js'), + 'utf8' +); const renderFormatMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/utils/render_format_methods.js'), 'utf8' @@ -49,6 +53,7 @@ const getSearchResultsPatchKind = eval(`(${extractMethod(resolveDownloadMethodsS const wireSearchDownloadButtons = eval(`(${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')})`); const patchLocalMatchesContainer = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchLocalMatchesContainer')})`); const resolveUrnAsync = eval(`(${extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync')})`); +const fetchUrnLocalMatches = eval(`(${extractMethod(searchPanelMethodsSource, 'fetchUrnLocalMatches')})`); const patchQueuedSelections = eval(`(${extractMethod(queueMethodsSource, 'patchQueuedSelections')})`); const patchDownloadsPanelElement = eval(`(${extractMethod(queueMethodsSource, 'patchDownloadsPanelElement')})`); const patchLoadedModelsProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'patchLoadedModelsProgress')})`); @@ -285,3 +290,46 @@ test('stale URN responses cannot overwrite the latest UI request', async () => { console.error = previousConsoleError; } }); + +test('URN local matches use the filename as part of the in-flight request identity', async () => { + const missing = { + key: 'missing-1', + category: 'checkpoints', + civitai_info: { expected_filename: 'old.safetensors' }, + matches: [], + }; + const resolvers = new Map(); + const calls = []; + const dialog = { + missingModels: [missing], + urnLocalMatchPromises: new Map(), + getMissingModelKey(item) { + return item.key; + }, + getUrnResolveKey() { + return '1:2:missing-1'; + }, + fetchLocalMatches(filename) { + calls.push(filename); + return new Promise(resolve => resolvers.set(filename, resolve)); + }, + preserveActiveDownloadLocalMatches(_missing, matches) { + return matches; + }, + }; + + const first = fetchUrnLocalMatches.call(dialog, missing); + await Promise.resolve(); + missing.civitai_info.expected_filename = 'new.safetensors'; + const second = fetchUrnLocalMatches.call(dialog, missing); + await Promise.resolve(); + + assert.deepEqual(calls, ['old.safetensors', 'new.safetensors']); + resolvers.get('new.safetensors')({ matches: [{ filename: 'new.safetensors' }] }); + await second; + resolvers.get('old.safetensors')({ matches: [{ filename: 'old.safetensors' }] }); + await first; + + assert.deepEqual(missing.matches, [{ filename: 'new.safetensors' }]); + assert.equal(missing.__urnLocalMatchesFilename, 'new.safetensors'); +}); diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 27991b2..bd35359 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -5250,6 +5250,12 @@ test('search and URN completion ignore stale UI runs', () => { const urnStart = resolveDownloadMethodsSource.indexOf('async resolveUrnAsync'); const searchOnline = resolveDownloadMethodsSource.slice(searchStart, urnStart); const resolveUrnAsync = extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync'); + const fetchUrnLocalMatches = extractMethod(searchPanelMethodsSource, 'fetchUrnLocalMatches'); + const refreshUrnLocalMatches = extractMethod(searchPanelMethodsSource, 'refreshUrnLocalMatches'); + const refreshLocalMatchesForMissing = extractMethod( + resolveDownloadMethodsSource, + 'refreshLocalMatchesForMissing' + ); assert.match(searchOnline, /isCurrentSearchRun/); assert.match(searchOnline, /currentSearchRun/); @@ -5258,6 +5264,14 @@ test('search and URN completion ignore stale UI runs', () => { assert.match(resolveUrnAsync, /urnResolveUiTokens/); assert.match(resolveUrnAsync, /isCurrentUrnUi/); assert.match(resolveUrnAsync, /else if \(data\) \{\s*return;/); + assert.match(fetchUrnLocalMatches, /getUrnResolveKey\(missing\).*filename/); + assert.match(fetchUrnLocalMatches, /expected_filename !== filename/); + assert.match(refreshUrnLocalMatches, /beginLocalMatchRefresh/); + assert.match(refreshUrnLocalMatches, /container\?\.isConnected !== false/); + assert.match(refreshUrnLocalMatches, /finishLocalMatchRefresh/); + assert.match(refreshLocalMatchesForMissing, /beginLocalMatchRefresh/); + assert.match(refreshLocalMatchesForMissing, /isCurrentLocalMatchRefresh/); + assert.match(refreshLocalMatchesForMissing, /finishLocalMatchRefresh/); }); test('local match status group warns when match folder is unsupported by node', () => { diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 0a4740f..2ddad19 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -466,6 +466,36 @@ export const resolveDownloadMethods = { } }, + getLocalMatchRefreshKey(missing = {}) { + return String( + this.getMissingModelKey?.(missing) + || this.getMissingSearchKey?.(missing) + || missing.original_path + || missing.name + || '' + ); + }, + + beginLocalMatchRefresh(missing = {}) { + if (!(this.localMatchRefreshTokens instanceof Map)) { + this.localMatchRefreshTokens = new Map(); + } + const key = this.getLocalMatchRefreshKey(missing); + const token = Symbol(key || 'local-match-refresh'); + this.localMatchRefreshTokens.set(key, token); + return { key, token }; + }, + + isCurrentLocalMatchRefresh(key, token) { + return this.localMatchRefreshTokens?.get(key) === token; + }, + + finishLocalMatchRefresh(key, token) { + if (this.isCurrentLocalMatchRefresh(key, token)) { + this.localMatchRefreshTokens.delete(key); + } + }, + getLocalMatchIdentity(match = {}) { return this.getLocalMatchIdentityKeys(match)[0] || ''; }, @@ -1483,38 +1513,48 @@ export const resolveDownloadMethods = { const targetFilename = this.getDownloadedLocalMatchTarget(missing, downloadedFilename); if (!targetFilename) return []; - if (progressDiv) { - progressDiv.innerHTML = this.renderDownloadStatusMessage( - `Checking local matches for ${targetFilename}...`, - 'info', - { filename: targetFilename, path: downloadPath, directory: downloadDirectory }, - { category, downloadPath, downloadDirectory, filename: targetFilename } - ); - } - const body = this.contentElement?.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`); - if (body) { - this.patchLocalMatchesContainer( - body, - `
Checking local matches for ${this.escapeHtml(targetFilename)}...
` - ); - } - - const data = await this.fetchLocalMatches(targetFilename, category || missing.category || '', true); - const matches = Array.isArray(data.matches) ? data.matches : []; const missingKey = this.getMissingModelKey(missing); + const refreshToken = this.beginLocalMatchRefresh(missing); const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing; - currentMissing.matches = matches; - missing.matches = matches; - this.rememberDownloadedLocalMatchesForMissing(currentMissing, matches, { - targetFilename, - category: category || missing.category || '', - downloadPath, - downloadDirectory - }); - this.allModels = null; - this.invalidateLoadedModelsCacheForActiveWorkflow?.(); - this.refreshLocalMatchesUiForMissing(currentMissing); - return matches; + + try { + if (progressDiv) { + progressDiv.innerHTML = this.renderDownloadStatusMessage( + `Checking local matches for ${targetFilename}...`, + 'info', + { filename: targetFilename, path: downloadPath, directory: downloadDirectory }, + { category, downloadPath, downloadDirectory, filename: targetFilename } + ); + } + const body = this.contentElement?.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`); + if (body) { + this.patchLocalMatchesContainer( + body, + `
Checking local matches for ${this.escapeHtml(targetFilename)}...
` + ); + } + + const data = await this.fetchLocalMatches(targetFilename, category || missing.category || '', true); + if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + return currentMissing.matches || []; + } + + const matches = Array.isArray(data.matches) ? data.matches : []; + currentMissing.matches = matches; + missing.matches = matches; + this.rememberDownloadedLocalMatchesForMissing(currentMissing, matches, { + targetFilename, + category: category || missing.category || '', + downloadPath, + downloadDirectory + }); + this.allModels = null; + this.invalidateLoadedModelsCacheForActiveWorkflow?.(); + this.refreshLocalMatchesUiForMissing(currentMissing); + return matches; + } finally { + this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token); + } }, refreshLocalMatchesUiForMissing(missing) { @@ -1559,6 +1599,7 @@ export const resolveDownloadMethods = { const missingKey = this.getMissingModelKey(missing); const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing; const previousMatches = this.cloneLocalMatches?.(currentMissing.matches || missing.matches || []) || []; + const refreshToken = this.beginLocalMatchRefresh(missing); try { if (button) { @@ -1573,6 +1614,9 @@ export const resolveDownloadMethods = { } const data = await this.fetchLocalMatches(targetFilename, missing.category || '', true); + if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + return currentMissing.matches || []; + } const refreshedMatches = Array.isArray(data.matches) ? data.matches : []; const matches = this.preserveActiveDownloadLocalMatches?.( currentMissing, @@ -1593,11 +1637,20 @@ export const resolveDownloadMethods = { this.allModels = null; this.invalidateLoadedModelsCacheForActiveWorkflow?.(); await minRefreshFeedback; + if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + return currentMissing.matches || []; + } this.refreshLocalMatchesUiForMissing(currentMissing); return matches; } catch (error) { + if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + return currentMissing.matches || []; + } await minRefreshFeedback; + if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + return currentMissing.matches || []; + } console.error('Model Resolver: local match refresh failed:', error); currentMissing.matches = previousMatches; missing.matches = previousMatches; @@ -1607,9 +1660,12 @@ export const resolveDownloadMethods = { } finally { refreshAnimation?.cancel?.(); if (button) { - button.disabled = false; - button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing'); + if (this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) { + button.disabled = false; + button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing'); + } } + this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token); } }, diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 646188c..0db7e7e 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -59,6 +59,7 @@ export class ResolverManagerDialog extends ComfyDialog { this.backendSearchProgressTimers = new Map(); this.urnResolvePromises = new Map(); this.urnLocalMatchPromises = new Map(); + this.localMatchRefreshTokens = new Map(); this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; this.cachedLoadedModelsData = null; diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 80e507d..cab5e77 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -2638,7 +2638,8 @@ export const searchPanelMethods = { return missing.matches; } - const key = this.getUrnResolveKey(missing); + const missingKey = this.getMissingModelKey(missing); + const key = `${this.getUrnResolveKey(missing)}:${filename}`; if (this.urnLocalMatchPromises.has(key)) { return this.urnLocalMatchPromises.get(key); } @@ -2646,9 +2647,14 @@ export const searchPanelMethods = { const promise = (async () => { const data = await this.fetchLocalMatches(filename, missing.category || '', false); const matches = Array.isArray(data.matches) ? data.matches : []; - missing.matches = this.preserveActiveDownloadLocalMatches?.(missing, matches) || matches; - missing.__urnLocalMatchesFilename = filename; - return missing.matches; + const currentMissing = (this.missingModels || []) + .find(item => this.getMissingModelKey(item) === missingKey) || missing; + if (currentMissing.civitai_info?.expected_filename !== filename) { + return currentMissing.matches || []; + } + currentMissing.matches = this.preserveActiveDownloadLocalMatches?.(currentMissing, matches) || matches; + currentMissing.__urnLocalMatchesFilename = filename; + return currentMissing.matches; })().finally(() => { this.urnLocalMatchPromises.delete(key); }); @@ -2660,18 +2666,25 @@ export const searchPanelMethods = { async refreshUrnLocalMatches(missing) { if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; + const filename = missing.civitai_info.expected_filename; const bodyId = `local-matches-body-${this.getMissingModelDomKey(missing)}`; const container = this.contentElement.querySelector(`#${bodyId}`); + const refreshToken = this.beginLocalMatchRefresh?.(missing); + const isCurrentRefresh = () => ( + !refreshToken + || this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token) + ); if (container) { this.patchLocalMatchesContainer( container, - `
Searching local matches for "${this.escapeHtml(missing.civitai_info.expected_filename)}"...
` + `
Searching local matches for "${this.escapeHtml(filename)}"...
` ); } try { await this.fetchUrnLocalMatches(missing); - if (container) { + if (!isCurrentRefresh() || missing.civitai_info?.expected_filename !== filename) return; + if (container?.isConnected !== false) { this.patchLocalMatchesContainer( container, this.renderLocalMatchesContent(missing, missing.__displayIndex || 0) @@ -2680,13 +2693,18 @@ export const searchPanelMethods = { } this.refreshMissingListRow(missing, { refreshBaseModels: true }); } catch (error) { + if (!isCurrentRefresh()) return; console.error('Model Resolver: URN local match refresh error:', error); - if (container) { + if (container?.isConnected !== false) { this.patchLocalMatchesContainer( container, '
Failed to refresh local matches.
' ); } + } finally { + if (refreshToken) { + this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token); + } } }, diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index 2dbc9cc..2c74d19 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -762,6 +762,7 @@ export const workflowStateMethods = { this.searchResultCache.clear(); this.urnResolvePromises.clear(); this.urnLocalMatchPromises.clear(); + this.localMatchRefreshTokens?.clear(); this.downloadTargetSelections?.clear(); this.pendingResolutions = []; this.rebuildPendingIndex();