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(); });