From ab31d0441064a3774811fc17dc44e6f9f73148f6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 10:53:00 +0200 Subject: [PATCH] Fix lifecycle review findings Advance each serialized web-provider queue when an aborted caller is released while retaining terminal observation of the underlying request. Preserve an accepted pending start across repeated stop requests and dispatch it exactly once after drain. Derive provider retry deadlines from eligible queued items, configured fallback chains, enabled accounts and keys, and matching provider or hoster cooldowns. Add RED-to-GREEN coverage for queue progress, repeated stop behavior, disabled accounts, alternative providers, unrelated hosters, and post-processing-only state. --- .../task-3-report.md | 28 +++++ src/main/all-debrid-web.ts | 7 +- src/main/bestdebrid-web.ts | 7 +- src/main/debrid.ts | 103 ++++++++++++++++-- src/main/download-manager.ts | 54 +++++++-- src/main/realdebrid-web.ts | 7 +- tests/alldebrid-web.test.ts | 23 +++- tests/bestdebrid-web.test.ts | 24 +++- tests/download-manager.test.ts | 87 +++++++++++++++ tests/realdebrid-web.test.ts | 24 +++- 10 files changed, 326 insertions(+), 38 deletions(-) diff --git a/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md index c6d23f7..2c7fc75 100644 --- a/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md +++ b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md @@ -43,3 +43,31 @@ Umgesetzt und fokussiert verifiziert. - Der vollständige Fünf-Dateien-Lauf erreichte vor der letzten Korrektur 395/397. Die beiden isolierten Fehler wurden anschließend einzeln und im finalen 7-Test-Manager-Gate grün verifiziert; die übrigen 393 Manager-/Web-/Renderer-Tests wurden nach dieser letzten kleinen Kompatibilitätskorrektur nicht nochmals gemeinsam ausgeführt. - Der Renderer-Build bleibt grün, meldet aber die bereits bestehende Warnung für einen JavaScript-Chunk über 500 kB. + +## Fixrunde 1/5 + +### Korrekturen + +- Die Webqueue-Tail folgt jetzt dem abortbaren Caller-Abschluss. Ein alter Roh-Request darf intern weiterlaufen, blockiert aber keine neue Real-Debrid-, AllDebrid- oder BestDebrid-Umwandlung mehr; seine späte terminale Rejection bleibt beobachtet. +- Ein weiterer `stop()` während eines bereits laufenden Stop-Drains bewahrt den angenommenen Startwunsch. Nach dem tatsächlichen Drain wird weiterhin genau ein Folgestart ausgeführt. +- Provider-Retry-Zeitpunkte werden aus der aktuellen startfähigen Queue abgeleitet. Berücksichtigt werden nur die reale Providerkette des Links sowie aktivierbare Accounts beziehungsweise Keys und passende Provider-/Hoster-Circuit-Breaker. +- Cooldowns deaktivierter Accounts, verfügbare Alternativprovider, fremde Provider oder Hoster und reine Nachbearbeitung erzeugen weder `waiting_provider` noch `retryAt`. + +### RED→GREEN + +- Alle drei Webqueues: Der zweite Request lief vor dem Fix in den 200-ms-Timeout, solange der erste Roh-Promise offen blieb; danach erreichte er den Provider und schloss erfolgreich ab, bevor die erste späte Rejection ausgelöst wurde. +- Doppelter Stop: Der zweite Stop setzte `pendingStart` vor dem Fix auf `false`; danach blieb der Wunsch sichtbar und es wurden insgesamt exakt zwei Provideraufrufe beobachtet. +- Cooldown-Kontext: Deaktivierter Account, verfügbarer Alternativprovider, fremder Hoster/Provider und reine Nachbearbeitung lieferten vor dem Fix falsche Retry-Zeitpunkte; danach blieben Phase und `retryAt` korrekt. Der wirklich blockierende Ein-Provider-Cooldown blieb grün und emittierte sein Ablaufereignis. + +### Verifikation + +- 9/9 fokussierte Manager-Regressionen bestanden. +- 152/152 Provider-/Renderer-Tests bestanden. +- `npx tsc --noEmit` bestand. +- `npm run build` bestand für Main und Renderer. +- `git diff --check` bestand. + +### Bedenken + +- Der vollständige Manager-Testlauf wurde in dieser Fixrunde nicht erneut ausgeführt; die geänderten Lifecycle-/Cooldown-Fälle sowie alle drei vollständigen Providerdateien und der vollständige Download-Renderer-Test wurden fokussiert geprüft. +- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert. diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 2bac815..0a9c6b6 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -315,10 +315,11 @@ export class AllDebridWebFallback { throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); } return job(); - }; + }; const run = this.queue.then(guardedJob, guardedJob); - this.queue = run.then(() => undefined, () => undefined); - return raceWithAbort(run, signal); + const result = raceWithAbort(run, signal); + this.queue = result.then(() => undefined, () => undefined); + return result; } private async ensureLoginWindow(): Promise { diff --git a/src/main/bestdebrid-web.ts b/src/main/bestdebrid-web.ts index 1742dfb..45ff079 100644 --- a/src/main/bestdebrid-web.ts +++ b/src/main/bestdebrid-web.ts @@ -273,10 +273,11 @@ export class BestDebridWebFallback { throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); } return job(); - }; + }; const run = this.queue.then(guardedJob, guardedJob); - this.queue = run.then(() => undefined, () => undefined); - return raceWithAbort(run, signal); + const result = raceWithAbort(run, signal); + this.queue = result.then(() => undefined, () => undefined); + return result; } private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> { diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 6c6960e..8664897 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -588,16 +588,6 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna }; } -export function getNextProviderRuntimeRetryAt(now = Date.now()): number | null { - const deadlines = [ - ...[...realDebridAccountCooldowns.values()].map((entry) => entry.until), - ...[...megaDebridAccountCooldowns.values()].map((entry) => entry.until), - ...debridLinkKeyCooldowns.values(), - ...debridLinkKeyHostCooldowns.values() - ].filter((deadline) => Number.isFinite(deadline) && deadline > now); - return deadlines.length > 0 ? Math.min(...deadlines) : null; -} - const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; const PROVIDER_LABELS: Record = { @@ -4098,7 +4088,7 @@ class DdownloadClient { } } -export class DebridService { +export class DebridService { private settings: AppSettings; private options: DebridServiceOptions; @@ -4115,7 +4105,7 @@ export class DebridService { this.options = options; } - public setSettings(next: AppSettings): void { + public setSettings(next: AppSettings): void { const prev = this.settings; this.settings = cloneSettings(next); @@ -4158,6 +4148,95 @@ export class DebridService { for (const keyId of nextDebridLinkKeyIds) validRuntimeKeys.add(`debridlink:${keyId}`); pruneAccountRuntimeSession(validRuntimeKeys); } + + public getBlockingProviderRetryAt(link: string, preferredLeadProvider: DebridProvider | null = null, now = Date.now()): number | null { + const settings = cloneSettings(this.settings); + const configuredOrder = settings.providerOrder && settings.providerOrder.length > 0 + ? uniqueProviderOrder(settings.providerOrder) + : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); + const orderedProviders = leadProviderChainWith(configuredOrder, preferredLeadProvider); + const hosterKey = extractHosterFromUrl(link); + const routedProvider = hosterKey ? settings.hosterRouting?.[hosterKey] : undefined; + const routedPlan = routedProvider + ? [routedProvider, ...orderedProviders.filter((provider) => provider !== routedProvider)] + : orderedProviders; + const plan = settings.autoProviderFallback || routedProvider ? routedPlan : routedPlan.slice(0, 1); + const deadlines: number[] = []; + const seen = new Set(); + for (const provider of plan) { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (seen.has(effectiveProvider)) { + continue; + } + seen.add(effectiveProvider); + const state = this.getProviderRuntimeWaitState(settings, effectiveProvider, hosterKey, now); + if (!state.configured) { + continue; + } + if (state.retryAt === null) { + return null; + } + deadlines.push(state.retryAt); + } + return deadlines.length > 0 ? Math.min(...deadlines) : null; + } + + private getProviderRuntimeWaitState( + settings: AppSettings, + provider: DebridProvider, + hosterKey: string, + now: number + ): { configured: boolean; retryAt: number | null } { + if ((settings.disabledProviders || []).includes(provider)) { + return { configured: false, retryAt: null }; + } + if (provider === "realdebrid") { + const accounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled + && !isRealDebridAccountDailyLimitReached(settings, account.id, now)); + if (accounts.length === 0) { + return { configured: false, retryAt: null }; + } + const deadlines = accounts.map((account) => getRealDebridAccountCooldown(account.id, now)?.until ?? null); + return deadlines.some((deadline) => deadline === null) + ? { configured: true, retryAt: null } + : { configured: true, retryAt: Math.min(...deadlines as number[]) }; + } + if (provider === "megadebrid-api" || provider === "megadebrid-web") { + const mode = provider === "megadebrid-web" ? "web" : "api"; + if (!isMegaDebridModeEnabled(settings, mode) || (mode === "web" && !this.options.megaWebUnrestrict)) { + return { configured: false, retryAt: null }; + } + const accounts = getMegaDebridAccountList(settings, mode).filter((account) => !isMegaDebridAccountDisabled(settings, account.id, mode) + && !isMegaDebridAccountDailyLimitReached(settings, account.id, now)); + if (accounts.length === 0) { + return { configured: false, retryAt: null }; + } + const deadlines = accounts.map((account) => getMegaDebridAccountCooldownState(`${account.id}:${mode}`, now)?.until ?? null); + return deadlines.some((deadline) => deadline === null) + ? { configured: true, retryAt: null } + : { configured: true, retryAt: Math.min(...deadlines as number[]) }; + } + if (provider === "debridlink") { + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter((key) => !isDebridLinkApiKeyDisabled(settings, key.id) + && !isDebridLinkApiKeyDailyLimitReached(settings, key.id, now)); + if (keys.length === 0) { + return { configured: false, retryAt: null }; + } + const deadlines: number[] = []; + for (const key of keys) { + const keyRetryAt = getDebridLinkKeyCooldownState(key.id, now)?.until ?? 0; + const hostRetryAt = getDebridLinkKeyHostCooldownState(key.id, hosterKey, now)?.until ?? 0; + const retryAt = Math.max(keyRetryAt, hostRetryAt); + if (retryAt === 0) { + return { configured: true, retryAt: null }; + } + deadlines.push(retryAt); + } + return { configured: true, retryAt: Math.min(...deadlines) }; + } + const configured = this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider); + return { configured, retryAt: null }; + } private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 01fbd05..7e1521a 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -62,7 +62,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getNextProviderRuntimeRetryAt, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -3940,12 +3940,47 @@ export class DownloadManager extends EventEmitter { } private getEarliestProviderRetryAt(now: number): number | null { - const deadlines = [...this.providerFailures.values()] - .map((entry) => entry.cooldownUntil) - .filter((deadline) => Number.isFinite(deadline) && deadline > now); - const runtimeRetryAt = getNextProviderRuntimeRetryAt(now); - if (runtimeRetryAt) { - deadlines.push(runtimeRetryAt); + const queuedItems: DownloadItem[] = []; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + continue; + } + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (item && (item.status === "queued" || item.status === "reconnect_wait")) { + queuedItems.push(item); + } + } + } + if (queuedItems.length === 0) { + return null; + } + const deadlines: number[] = []; + for (const item of queuedItems) { + const itemDeadlines: number[] = []; + const failureKey = this.getProviderFailureKeyForItem(item); + const localRetryAt = this.providerFailures.get(failureKey)?.cooldownUntil || 0; + const localFallback = localRetryAt > now && this.settings.autoProviderFallback + ? this.findFallbackProviderNotInCooldown(item) + : null; + const hasLocalFallback = localRetryAt > now + && this.settings.autoProviderFallback + && localFallback !== null; + if (localRetryAt > now && !hasLocalFallback) { + itemDeadlines.push(localRetryAt); + } + const runtimeRetryAt = this.debridService.getBlockingProviderRetryAt(item.url, localFallback, now); + if (runtimeRetryAt) { + itemDeadlines.push(runtimeRetryAt); + } + if (itemDeadlines.length === 0) { + return null; + } + deadlines.push(Math.min(...itemDeadlines)); } return deadlines.length > 0 ? Math.min(...deadlines) : null; } @@ -6687,10 +6722,13 @@ export class DownloadManager extends EventEmitter { public stop(options?: { parkForRestart?: boolean }): void { const parkForRestart = options?.parkForRestart === true; + const wasStopping = this.lifecyclePhase === "stopping"; this.lifecycleGeneration += 1; this.lifecyclePhase = "stopping"; this.lifecycleReason = "Laufende Arbeit wird beendet"; - this.pendingStartOptions = null; + if (!wasStopping) { + this.pendingStartOptions = null; + } this.healthManualStop = !parkForRestart; this.healthShuttingDown = parkForRestart; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index d36a597..f582b1b 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -334,10 +334,11 @@ export class RealDebridWebFallback { throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); } return job(); - }; + }; const run = this.queue.then(guardedJob, guardedJob); - this.queue = run.then(() => undefined, () => undefined); - return raceWithAbort(run, signal); + const result = raceWithAbort(run, signal); + this.queue = result.then(() => undefined, () => undefined); + return result; } private async ensureLoginWindow(): Promise { diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index b4a749e..00a94f6 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -144,9 +144,15 @@ describe("alldebrid-web", () => { it("releases an aborted caller while the active web request ignores its signal", async () => { let rejectRequest!: (error: Error) => void; - mockFetch.mockReturnValue(new Promise((_resolve, reject) => { - rejectRequest = reject; - })); + mockFetch + .mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectRequest = reject; + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + link: "https://alldebrid.direct/second.bin", + filename: "second.bin", + filesize: 333 + }), { status: 200 })); const fallback = new AllDebridWebFallback(() => true); const controller = new AbortController(); const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal) @@ -160,6 +166,17 @@ describe("alldebrid-web", () => { ]); expect(outcome).toContain("aborted:alldebrid-web"); + const secondOutcome = await Promise.race([ + fallback.unrestrict("https://rapidgator.net/file/second"), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)) + ]); + expect(secondOutcome).toEqual({ + directUrl: "https://alldebrid.direct/second.bin", + fileName: "second.bin", + fileSize: 333, + retriesUsed: 0 + }); + expect(mockFetch).toHaveBeenCalledTimes(2); rejectRequest(new Error("late alldebrid rejection")); await Promise.resolve(); }); diff --git a/tests/bestdebrid-web.test.ts b/tests/bestdebrid-web.test.ts index 94b487d..fcdcb61 100644 --- a/tests/bestdebrid-web.test.ts +++ b/tests/bestdebrid-web.test.ts @@ -174,9 +174,16 @@ describe("bestdebrid-web", () => { const fallback = new BestDebridWebFallback(() => true); await fallback.importCookiesFromFile(filePath); let rejectRequest!: (error: Error) => void; - mockFetch.mockReturnValue(new Promise((_resolve, reject) => { - rejectRequest = reject; - })); + mockFetch + .mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectRequest = reject; + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: 0, + link: "https://bestdebrid.direct/second.bin", + filename: "second.bin", + size: "444 B" + }), { status: 200 })); const controller = new AbortController(); const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal) .then(() => "resolved" as const, (error) => String(error)); @@ -189,6 +196,17 @@ describe("bestdebrid-web", () => { ]); expect(outcome).toContain("aborted:bestdebrid-web"); + const secondOutcome = await Promise.race([ + fallback.unrestrict("https://1fichier.com/?second"), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)) + ]); + expect(secondOutcome).toEqual({ + directUrl: "https://bestdebrid.direct/second.bin", + fileName: "second.bin", + fileSize: 444, + retriesUsed: 0 + }); + expect(mockFetch).toHaveBeenCalledTimes(2); rejectRequest(new Error("late bestdebrid rejection")); await Promise.resolve(); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index e43cc26..ae8e2da 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -847,6 +847,8 @@ describe("deterministic stop and restart lifecycle", () => { manager.stop(); await manager.start(); + expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true }); + manager.stop(); expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true }); finishFirstAbort(); await waitFor(() => attempts.length === 2); @@ -854,6 +856,8 @@ describe("deterministic stop and restart lifecycle", () => { session: { running: true }, lifecycle: { phase: "running", pendingStart: false } }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(attempts).toHaveLength(2); manager.stop(); }); @@ -945,6 +949,89 @@ describe("deterministic stop and restart lifecycle", () => { vi.useRealTimers(); }); + it("ignores cooldowns from disabled accounts when projecting provider wait state", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-cooldown-context-")); + tempDirs.push(root); + const accountId = "rda_disabled_cooldown"; + const manager = new DownloadManager( + { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]), + realDebridDisabledAccountIds: [accountId], + providerOrder: ["realdebrid"], + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "disabled", links: ["https://rapidgator.net/file/disabled"] }]); + primeRealDebridRuntimeCooldownForTests(accountId, 60_000); + + expect(manager.getSnapshot()).toMatchObject({ + canStart: false, + lifecycle: { phase: "idle", retryAt: null } + }); + }); + + it("ignores a primary provider cooldown when an alternative provider can start the queue", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alternative-provider-context-")); + tempDirs.push(root); + const accountId = "rda_alternative_provider"; + const manager = new DownloadManager( + { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]), + allDebridToken: "all-debrid-token", + providerOrder: ["realdebrid", "alldebrid"], + autoProviderFallback: true, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "alternative", links: ["https://rapidgator.net/file/alternative"] }]); + primeRealDebridRuntimeCooldownForTests(accountId, 60_000); + + expect(manager.getSnapshot()).toMatchObject({ + canStart: true, + lifecycle: { phase: "idle", retryAt: null } + }); + }); + + it("ignores cooldowns for another provider or hoster and for pure post-processing", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-")); + tempDirs.push(root); + const manager = new DownloadManager( + { + ...defaultSettings(), + oneFichierApiKey: "onefichier-key", + providerOrder: ["onefichier"], + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "other-hoster", links: ["https://1fichier.com/?other-hoster"] }]); + const internal = manager as unknown as { + packagePostProcessTasks: Map>; + providerFailures: Map; + session: ReturnType; + }; + internal.providerFailures.set("alldebrid:rapidgator", { + count: 20, + lastFailAt: Date.now(), + cooldownUntil: Date.now() + 60_000 + }); + + expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "idle", retryAt: null }); + + const itemId = Object.keys(internal.session.items)[0]; + internal.session.items[itemId].status = "completed"; + internal.packagePostProcessTasks.set("postprocess-only", new Promise(() => {})); + + expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "postprocessing", retryAt: null }); + }); + it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-")); tempDirs.push(root); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 90fc324..6b02ce9 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -193,9 +193,16 @@ describe("realdebrid-web", () => { it("releases an aborted caller while the active web request ignores its signal", async () => { let rejectRequest!: (error: Error) => void; - mockSessionFetch.mockReturnValue(new Promise((_resolve, reject) => { - rejectRequest = reject; - })); + mockSessionFetch + .mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectRequest = reject; + })) + .mockResolvedValueOnce(new Response("", { status: 200 })); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + download: "https://cdn.real-debrid.example/second.bin", + filename: "second.bin", + filesize: 222 + }), { status: 200 }))); const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort_race", () => true); const controller = new AbortController(); const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal) @@ -209,6 +216,17 @@ describe("realdebrid-web", () => { ]); expect(outcome).toContain("aborted:realdebrid-web"); + const secondOutcome = await Promise.race([ + fallback.unrestrict("https://rapidgator.net/file/second"), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)) + ]); + expect(secondOutcome).toEqual({ + directUrl: "https://cdn.real-debrid.example/second.bin", + fileName: "second.bin", + fileSize: 222, + retriesUsed: 0 + }); + expect(mockSessionFetch).toHaveBeenCalledTimes(2); rejectRequest(new Error("late realdebrid rejection")); await Promise.resolve(); });