fix(frontend): ignore stale local match refreshes

This commit is contained in:
Dariusz L
2026-08-04 20:12:46 +02:00
parent 746f7d3ec4
commit 7b4a2e607e
6 changed files with 177 additions and 39 deletions
+48
View File
@@ -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');
});
@@ -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', () => {
@@ -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,
`<div class="mr-no-matches">Checking local matches for ${this.escapeHtml(targetFilename)}...</div>`
);
}
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,
`<div class="mr-no-matches">Checking local matches for ${this.escapeHtml(targetFilename)}...</div>`
);
}
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);
}
},
+1
View File
@@ -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;
+25 -7
View File
@@ -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,
`<div class="mr-no-matches">Searching local matches for "${this.escapeHtml(missing.civitai_info.expected_filename)}"...</div>`
`<div class="mr-no-matches">Searching local matches for "${this.escapeHtml(filename)}"...</div>`
);
}
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,
'<div class="mr-no-matches">Failed to refresh local matches.</div>'
);
}
} finally {
if (refreshToken) {
this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token);
}
}
},
@@ -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();