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();