fix: actually halt bulk operations on cancel — frontend AbortController + backend guards (#986)

This commit is contained in:
Will Miao
2026-06-17 07:20:32 +08:00
parent 4199c30fec
commit 0906c484e9
16 changed files with 116 additions and 16 deletions

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
"cancelling": "Abbrechen...",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"date": "Datum", "date": "Datum",
"version": "Version", "version": "Version",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
"cancelling": "Cancelling...",
"unknown": "Unknown", "unknown": "Unknown",
"date": "Date", "date": "Date",
"version": "Version", "version": "Version",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
"cancelling": "Cancelando...",
"unknown": "Desconocido", "unknown": "Desconocido",
"date": "Fecha", "date": "Fecha",
"version": "Versión", "version": "Versión",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
"cancelling": "Annulation...",
"unknown": "Inconnu", "unknown": "Inconnu",
"date": "Date", "date": "Date",
"version": "Version", "version": "Version",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
"cancelling": "מבטל...",
"unknown": "לא ידוע", "unknown": "לא ידוע",
"date": "תאריך", "date": "תאריך",
"version": "גרסה", "version": "גרסה",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
"cancelling": "キャンセル中...",
"unknown": "不明", "unknown": "不明",
"date": "日付", "date": "日付",
"version": "バージョン", "version": "バージョン",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
"cancelling": "취소 중...",
"unknown": "알 수 없음", "unknown": "알 수 없음",
"date": "날짜", "date": "날짜",
"version": "버전", "version": "버전",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
"cancelling": "Отмена...",
"unknown": "Неизвестно", "unknown": "Неизвестно",
"date": "Дата", "date": "Дата",
"version": "Версия", "version": "Версия",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",

View File

@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",

View File

@@ -532,6 +532,13 @@ class ModelScanner:
if not scan_result or not getattr(self, '_persistent_cache', None): if not scan_result or not getattr(self, '_persistent_cache', None):
return return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
)
return
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index) hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: try:
@@ -705,14 +712,20 @@ class ModelScanner:
# Determine the page type based on model type # Determine the page type based on model type
# Scan for new data # Scan for new data
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result) if not self.is_cancelled():
await self._save_persistent_cache(scan_result) await self._apply_scan_result(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan') await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, " f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models" f"found {len(scan_result.raw_data)} models"
) )
else:
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds"
)
except Exception as e: except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}") logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
# Ensure cache is at least an empty structure on error # Ensure cache is at least an empty structure on error
@@ -1096,6 +1109,13 @@ class ModelScanner:
if scan_result is None: if scan_result is None:
return return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
"after cancellation"
)
return
self._hash_index = scan_result.hash_index self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count) self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models) self._excluded_models = list(scan_result.excluded_models)
@@ -1764,6 +1784,13 @@ class ModelScanner:
""" """
if not file_paths or self._cache is None: if not file_paths or self._cache is None:
return False return False
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
"after cancelled bulk delete"
)
return False
try: try:
# Get all models that need to be removed from cache # Get all models that need to be removed from cache

View File

@@ -468,17 +468,21 @@ export class BaseModelApiClient {
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
const abortController = new AbortController();
try { try {
state.loadingManager.show( state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`, `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0 0
); );
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url); const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
@@ -494,6 +498,10 @@ export class BaseModelApiClient {
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error); console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally { } finally {
@@ -948,13 +956,19 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
model_ids: modelIds, model_ids: modelIds,
force force
@@ -979,6 +993,10 @@ export class BaseModelApiClient {
return payload; return payload;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for models:', error); console.error('Error refreshing updates for models:', error);
throw error; throw error;
} finally { } finally {
@@ -991,13 +1009,19 @@ export class BaseModelApiClient {
throw new Error('No folder path provided'); throw new Error('No folder path provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
folder_path: folderPath, folder_path: folderPath,
force force
@@ -1022,6 +1046,10 @@ export class BaseModelApiClient {
return payload; return payload;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for folder:', error); console.error('Error refreshing updates for folder:', error);
throw error; throw error;
} finally { } finally {
@@ -1471,15 +1499,21 @@ export class BaseModelApiClient {
throw new Error('No file paths provided'); throw new Error('No file paths provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.bulkDelete, { const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
file_paths: filePaths file_paths: filePaths
}) })
@@ -1502,6 +1536,10 @@ export class BaseModelApiClient {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`); throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
return { success: false, cancelled: true };
}
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error); console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error; throw error;
} finally { } finally {

View File

@@ -611,7 +611,9 @@ export class BulkManager {
const result = await apiClient.bulkDeleteModels(filePaths); const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) { if (result?.cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig(); const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', { showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count, count: result.deleted_count,

View File

@@ -73,7 +73,7 @@ export class LoadingManager {
if (this.onCancelCallback) { if (this.onCancelCallback) {
this.onCancelCallback(); this.onCancelCallback();
this.cancelButton.disabled = true; this.cancelButton.disabled = true;
this.cancelButton.textContent = translate('common.status.loading', {}, 'Loading...'); this.cancelButton.textContent = translate('common.status.cancelling', {}, 'Cancelling...');
} }
}; };

View File

@@ -42,7 +42,12 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
onStart?.({ displayName, loadingMessage }); onStart?.({ displayName, loadingMessage });
state.loadingManager?.showSimpleLoading?.(loadingMessage); state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success'; let status = 'success';
let records = []; let records = [];
@@ -52,6 +57,7 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
const response = await fetch(apiConfig.endpoints.refreshUpdates, { const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ force: false }) body: JSON.stringify({ force: false })
}); });
@@ -81,6 +87,11 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
await resetAndReload(false); await resetAndReload(false);
} catch (err) { } catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', displayName, records: [], error: null };
}
status = 'error'; status = 'error';
error = err instanceof Error ? err : new Error(String(err)); error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking model updates:', error); console.error('Error checking model updates:', error);
@@ -126,7 +137,12 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
); );
state.loadingManager?.showSimpleLoading?.(loadingMessage); state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success'; let status = 'success';
let records = []; let records = [];
@@ -136,6 +152,7 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
const response = await fetch(apiConfig.endpoints.refreshUpdates, { const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ folder_path: folderPath, force: false }) body: JSON.stringify({ folder_path: folderPath, force: false })
}); });
@@ -165,6 +182,11 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
await resetAndReload(false); await resetAndReload(false);
} catch (err) { } catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', records: [], error: null };
}
status = 'error'; status = 'error';
error = err instanceof Error ? err : new Error(String(err)); error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking folder model updates:', error); console.error('Error checking folder model updates:', error);

View File

@@ -2190,6 +2190,7 @@ describe('Interaction-level regression coverage', () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }), body: JSON.stringify({ force: false }),
signal: expect.any(AbortSignal),
}); });
const updateResponse = await global.fetch.mock.results[1].value; const updateResponse = await global.fetch.mock.results[1].value;