fix(frontend): ignore stale local match refreshes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user