From d135a17f1b7c499860fa97d31633d623a4ac0040 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 15 Aug 2026 23:32:43 +0330 Subject: [PATCH] fix(downloads): allow confirmed credentialless resume - distinguish redacted session credentials from intrinsic URL authentication - requeue confirmed resumes without saved secrets from the table and Properties window - scope batch overrides to approved rows and harden malformed bridge payloads - add regression coverage and localized confirmation copy --- src/components/DownloadTable.tsx | 38 ++++++- src/components/PropertiesWindowApp.tsx | 11 +- src/components/PropertiesWindowBridgeHost.tsx | 9 +- src/i18n/catalogs/en.ts | 3 +- src/i18n/catalogs/fa.ts | 3 +- src/i18n/catalogs/he.ts | 3 +- src/i18n/catalogs/ru.ts | 3 +- src/i18n/catalogs/uk.ts | 3 +- src/i18n/catalogs/zh-CN.ts | 3 +- src/propertiesBridge.ts | 3 +- src/store/useDownloadStore.test.ts | 106 ++++++++++++++++++ src/store/useDownloadStore.ts | 73 ++++++++---- 12 files changed, 222 insertions(+), 36 deletions(-) diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index d07f8ee..1fc5abe 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1875,19 +1875,28 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const handleResume = useCallback(async (item: DownloadItem) => { try { - const resumed = await useDownloadStore.getState().resumeDownload(item.id); + const current = useDownloadStore.getState().downloads.find(download => download.id === item.id); + if (!current) return; + let resumeWithoutCredentials = false; + if (current.credentialsRequired === true) { + resumeWithoutCredentials = window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm)); + if (!resumeWithoutCredentials) return; + } + const resumed = await useDownloadStore.getState().resumeDownload(item.id, { + resumeWithoutCredentials + }); if (!resumed) { - const current = useDownloadStore.getState().downloads.find( + const latest = useDownloadStore.getState().downloads.find( download => download.id === item.id ); - const reason = current?.lastError?.trim(); + const reason = latest?.lastError?.trim(); throw new Error(reason || t($ => $.downloadTable.backendRejectedStart)); } } catch (error) { console.error("Failed to resume:", error); showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: item.fileName }), error); } - }, [showInteractionError]); + }, [showInteractionError, t]); const getCurrentSelectedDownloads = useCallback(() => { const selected = selectedIdsRef.current; @@ -1919,7 +1928,26 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const handleResumeSelected = useCallback(() => { const ids = Array.from(selectedIdsRef.current); if (ids.length === 0) return; - void startSelected(ids).catch(error => { + const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id)); + const credentialMarkedIds = selected + .filter(download => download.credentialsRequired === true) + .map(download => download.id); + if (credentialMarkedIds.length > 0 + && !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) { + // Continue ordinary selected resumes. Credential-marked rows remain + // fail-closed and can be handled individually after the user supplies + // credentials or confirms a credentialless retry. + const credentialMarkedIdSet = new Set(credentialMarkedIds); + const ordinaryIds = ids.filter(id => !credentialMarkedIdSet.has(id)); + if (ordinaryIds.length === 0) return; + void startSelected(ordinaryIds).catch(error => { + showInteractionError(t($ => $.downloadTable.resumeFailed), error); + }); + return; + } + void startSelected(ids, { + resumeWithoutCredentialsIds: credentialMarkedIds + }).catch(error => { showInteractionError(t($ => $.downloadTable.resumeFailed), error); }); }, [showInteractionError, startSelected, t]); diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 4371353..42b84ba 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -1236,7 +1236,16 @@ export const PropertiesWindowApp = () => { && !window.confirm(t($ => $.downloadTable.nonResumableOne))) { return; } - void requestAction('pause-resume'); + const resumeWithoutCredentials = (lifecycleAction === 'resume' || lifecycleAction === 'retry') + && snapshot.credentialsRequired === true; + if (resumeWithoutCredentials + && !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) { + return; + } + void requestAction( + 'pause-resume', + resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined, + ); }} > {lifecycleAction === 'pause' ? : } diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 8507273..02c1db7 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -476,7 +476,14 @@ export const PropertiesWindowBridgeHost = () => { throw new Error('The download did not reach a paused or terminal state'); } } else { - const resumed = await store.resumeDownload(request.downloadId); + const resumeWithoutCredentials = typeof request.payload === 'object' + && request.payload !== null + && 'resumeWithoutCredentials' in request.payload + && request.payload.resumeWithoutCredentials === true; + const resumed = await store.resumeDownload( + request.downloadId, + resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined, + ); if (!resumed) { throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart)); } diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index aa51109..dd2c418 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -272,7 +272,8 @@ const common = { liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}', liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.', editingUnavailable: 'These properties cannot be edited while the download is active.', - credentialsRequired: 'Credentials are required again after restart. Add them in Advanced and resume this download.', + credentialsRequired: 'Credentials, cookies, or request headers from the previous session were not saved. Add them in Advanced, or confirm a retry without them.', + resumeWithoutCredentialsConfirm: 'This download used credentials, cookies, or request headers that are no longer available. Retry without them? If access is required, the server may reject the request.', liveTorrentUploadLimit: 'Live Torrent upload limit', liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.', liveTorrentUploadLimitPlaceholder: 'e.g. 1024K', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 3b074f8..51986de 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -272,7 +272,8 @@ const fa = { liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}', liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.', editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگی‌ها ممکن نیست.', - credentialsRequired: 'پس از راه‌اندازی مجدد، دوباره اطلاعات ورود لازم است. آن‌ها را در بخش پیشرفته وارد و دانلود را ادامه دهید.', + credentialsRequired: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های درخواستِ نشست قبلی ذخیره نشده‌اند. آن‌ها را در بخش پیشرفته وارد کنید یا ادامه‌دادن بدون آن‌ها را تأیید کنید.', + resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های این دانلود دیگر در دسترس نیستند. دانلود بدون آن‌ها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.', liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت', liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.', liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index b2e8fde..324bd9f 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -272,7 +272,8 @@ const he = { liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}', liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.', editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.', - credentialsRequired: 'לאחר הפעלה מחדש נדרשים שוב פרטי התחברות. הוסף אותם במתקדם והמשך את ההורדה.', + credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.', + resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.', liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת', liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.', liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index bf4ba97..b0a00ce 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -272,7 +272,8 @@ const ru = { liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}', liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.', editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.', - credentialsRequired: 'После перезапуска снова нужны учётные данные. Добавьте их в разделе «Дополнительно» и возобновите загрузку.', + credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.', + resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.', liveTorrentUploadLimit: 'Текущий лимит отдачи торрента', liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.', liveTorrentUploadLimitPlaceholder: 'например, 1024K', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index e4706f7..d9a8d36 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -272,7 +272,8 @@ const uk = { liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}', liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.', editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.', - credentialsRequired: 'Після перезапуску облікові дані потрібні знову. Додайте їх у розділі «Додатково» та відновіть завантаження.', + credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.', + resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.', liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента', liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.', liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index bf75e4d..eaecd19 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -272,7 +272,8 @@ const zhCN = { liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}', liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。', editingUnavailable: '下载进行时无法编辑这些属性。', - credentialsRequired: '重启后需要再次提供凭据。请在“高级”中添加凭据,然后恢复下载。', + credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。', + resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。', liveTorrentUploadLimit: '实时种子上传限速', liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。', liveTorrentUploadLimitPlaceholder: '例如 1024K', diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 252fdcf..421ce9e 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -301,7 +301,8 @@ export type PropertiesActionRequest = { payload?: PropertiesPatch | { selectedIndices: number[] | null } | { limit: string | null } - | { maxPeers: string | null; peerSpeedLimit: string | null }; + | { maxPeers: string | null; peerSpeedLimit: string | null } + | { resumeWithoutCredentials: boolean }; }; export type PropertiesActionResult = { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 063cb8a..b9158f4 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1536,6 +1536,68 @@ describe('useDownloadStore', () => { expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']); }); + it('limits credentialless selected resume to the explicitly approved rows', async () => { + useDownloadStore.setState({ + downloads: [ + { + id: 'selected-with-credentials', + url: 'http://with-credentials', + fileName: 'with-credentials', + destination: '/tmp', + status: 'paused', + category: 'Other', + dateAdded: '', + queueId: 'selection-credential-scope', + queuePosition: 0, + password: 'secret', + }, + { + id: 'selected-without-credentials', + url: 'http://without-credentials', + fileName: 'without-credentials', + destination: '/tmp', + status: 'paused', + category: 'Other', + dateAdded: '', + queueId: 'selection-credential-scope', + queuePosition: 1, + credentialsRequired: true, + }, + ] as any[], + }); + + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => { + if (command === 'enqueue_download') { + const item = (args as { item: { id: string; password: string | null } }).item; + return { id: item.id, filename: item.id }; + } + if (command === 'get_pending_order') return [ + 'selected-with-credentials', + 'selected-without-credentials', + ]; + if (command === 'move_many_in_queue') return [ + 'selected-with-credentials', + 'selected-without-credentials', + ]; + return undefined; + }); + + await expect(useDownloadStore.getState().startSelected([ + 'selected-with-credentials', + 'selected-without-credentials', + ], { + resumeWithoutCredentialsIds: ['selected-without-credentials'], + })).resolves.toBe(2); + + const enqueues = vi.mocked(ipc.invokeCommand).mock.calls + .filter(([command]) => command === 'enqueue_download') + .map(([, args]) => (args as { item: { id: string; password: string | null } }).item); + expect(enqueues).toEqual([ + expect.objectContaining({ id: 'selected-with-credentials', password: 'secret' }), + expect.objectContaining({ id: 'selected-without-credentials', password: null }), + ]); + }); + it('pauses queued items through the global pause action', async () => { useDownloadStore.setState({ downloads: [ @@ -2247,6 +2309,50 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().downloads[0].status).toBe('paused'); }); + it('explicitly requeues a credential-marked download without saved credentials', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'credentialless-resume', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + destination: '/tmp', + status: 'paused', + category: 'Other', + dateAdded: '', + credentialsRequired: true, + hasBeenDispatched: true, + }] as any[], + backendRegisteredIds: new Set(['credentialless-resume']) + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'get_pending_order') return ['credentialless-resume']; + if (command === 'enqueue_download') return { id: 'credentialless-resume', filename: 'file.bin' }; + return undefined; + }); + + await expect(useDownloadStore.getState().resumeDownload('credentialless-resume', { + resumeWithoutCredentials: true + })).resolves.toBe(true); + + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything()); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything()); + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ + item: expect.objectContaining({ + username: null, + password: null, + cookies: null, + headers: null, + }) + }) + ); + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + credentialsRequired: false, + status: 'queued', + }); + }); + it('preserves backend rejection reasons while auto-resuming saved queued items', async () => { vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { if (cmd === 'db_get_all_queues') return []; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index f24f3c8..6dd6bac 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -45,6 +45,11 @@ const downloadControlIntents = new Map(); export interface ResumeDownloadOptions { preserveQueuePosition?: boolean; forceRequeue?: boolean; + resumeWithoutCredentials?: boolean; +} + +export interface StartSelectedOptions { + resumeWithoutCredentialsIds?: readonly string[]; } // State events do not carry a lifecycle generation. Keep the intent that @@ -1059,7 +1064,7 @@ interface DownloadState { pauseDownload: (id: string) => Promise; redownload: (id: string) => Promise; resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise; - startSelected: (ids: string[]) => Promise; + startSelected: (ids: string[], options?: StartSelectedOptions) => Promise; startQueue: (queueId: string) => Promise; pauseQueue: (queueId: string) => Promise; startAll: () => Promise; @@ -1223,26 +1228,48 @@ export const useDownloadStore = create((set, get) => { let targetItem = get().downloads.find(d => d.id === id); if (!targetItem) return false; + const requestedResumeWithoutCredentials = options.resumeWithoutCredentials === true; + const resumeWithoutCredentials = requestedResumeWithoutCredentials + && targetItem.credentialsRequired === true; + const forceRequeue = options.forceRequeue === true || resumeWithoutCredentials; + const preserveQueuePosition = options.preserveQueuePosition === true || resumeWithoutCredentials; + + if (resumeWithoutCredentials) { + // An explicit credentialless retry must not reuse secrets retained by + // the in-memory row or by a paused daemon lifecycle. Requeueing below + // creates a fresh backend lifecycle with the redacted payload. + get().updateDownload(id, { + username: undefined, + password: undefined, + cookies: undefined, + headers: undefined, + }); + targetItem = get().downloads.find(download => download.id === id); + if (!targetItem) return false; + } + if (targetItem.credentialsRequired === true && !hasCredentialMaterial(targetItem.password) && !hasCredentialMaterial(targetItem.cookies) && !hasCredentialMaterial(targetItem.headers)) { - const settings = useSettingsStore.getState(); - const login = getSiteLogin(targetItem.url, settings); - let keychainPassword: string | null = null; - if (login && settings.keychainAccessReady) { - try { - keychainPassword = await invoke('get_keychain_password', { id: login.id }); - } catch (error) { - console.warn('Could not fetch keychain password for resume:', error); + if (!resumeWithoutCredentials) { + const settings = useSettingsStore.getState(); + const login = getSiteLogin(targetItem.url, settings); + let keychainPassword: string | null = null; + if (login && settings.keychainAccessReady) { + try { + keychainPassword = await invoke('get_keychain_password', { id: login.id }); + } catch (error) { + console.warn('Could not fetch keychain password for resume:', error); + } } - } - if (!hasCredentialMaterial(keychainPassword)) { - if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) { - settings.setShowKeychainModal(true); + if (!hasCredentialMaterial(keychainPassword)) { + if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) { + settings.setShowKeychainModal(true); + } + markCredentialsRequired(id); + return false; } - markCredentialsRequired(id); - return false; } clearCredentialsRequired(id); } @@ -1250,7 +1277,7 @@ export const useDownloadStore = create((set, get) => { setDownloadControlIntent(id, 'resume'); let previousStatus = targetItem.status; try { - if (options.forceRequeue) { + if (forceRequeue) { // Fence any older enqueue before replacing a paused backend lifecycle. // Otherwise a late addUri result can win the race and make this // selection start outside the requested order. @@ -1267,7 +1294,7 @@ export const useDownloadStore = create((set, get) => { const currentTargetItem = targetItem; if ( - options.forceRequeue && + forceRequeue && currentTargetItem.status === 'paused' && get().backendRegisteredIds.has(id) ) { @@ -1275,7 +1302,7 @@ export const useDownloadStore = create((set, get) => { get().unregisterBackendIds([id]); } - if (options.forceRequeue) { + if (forceRequeue) { set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== id) })); @@ -1311,7 +1338,7 @@ export const useDownloadStore = create((set, get) => { (d.queueId || MAIN_QUEUE_ID) === (currentTargetItem.queueId || MAIN_QUEUE_ID) ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); - const queuePosition = options.preserveQueuePosition + const queuePosition = preserveQueuePosition ? currentTargetItem.queuePosition : maxPos + 1; @@ -1340,7 +1367,7 @@ export const useDownloadStore = create((set, get) => { return false; } - const resumedExisting = options.forceRequeue + const resumedExisting = forceRequeue ? false : await invoke('resume_download', { id, @@ -1987,9 +2014,10 @@ export const useDownloadStore = create((set, get) => { true, preemptDispatch ), - startSelected: (ids) => { + startSelected: (ids, options = {}) => { const orderedIds = [...new Set(ids)]; if (orderedIds.length === 0) return Promise.resolve(0); + const resumeWithoutCredentialsIds = new Set(options.resumeWithoutCredentialsIds ?? []); return runDownloadLifecycleOperations(orderedIds, 'start-selected', async () => { await waitForPendingStartupResume(); @@ -2033,7 +2061,8 @@ export const useDownloadStore = create((set, get) => { } const resumed = await resumeDownloadInternal(id, { preserveQueuePosition: true, - forceRequeue: true + forceRequeue: true, + resumeWithoutCredentials: resumeWithoutCredentialsIds.has(id) }); if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) { // A queue pause can win while this item's requeue is in flight. The