From c3590f08fcbbc27a5a44b75b7dac13d67fc5d2bc Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 19 Apr 2026 21:59:28 +0200 Subject: [PATCH] Fix stale-state when account credentials change at runtime Reported user bug: "When I add an account, remove it, add a new one, the new account is only really used after restart." Multiple cache layers were not invalidated when settings changed, causing the system to keep using stale state until the app was restarted. Three layers of caching needed invalidation: 1. DebridService cached client instances (debrid.ts ~3067) The cached DebridLinkClient / LinkSnappyClient / DdownloadClient hold internal state (session cookies, auth tokens, parsed key lists). Without explicit invalidation when credentials change, the OLD client instance keeps serving requests until apiKeysRaw / login+password no longer match the cache key - which doesn't always trigger because the cache key may incidentally match (e.g. user removes and re-adds the same key). Fix: setSettings() now compares previous vs next credentials per provider and explicitly clears the cache when they differ. 2. MegaDebridClient.cachedApiTokens (debrid.ts ~1533) Static module-level token cache keyed by login (lowercase). When a user changes the password for an existing login (same login, new password), the cached token was kept for up to 20 minutes and would only get cleared after the API returned 401/403. Fix: Two new static methods - pruneCachedTokensNotIn() removes entries whose login is no longer in the active list, and clearCachedApiToken() force-clears a specific login. Both called from setSettings() based on diff between previous and next account list. 3. Module-level Debrid-Link cooldown maps (debrid.ts ~41-51) debridLinkKeyCooldowns / debridLinkKeyCooldownDetails / runtime statuses are keyed by API key ID (FNV-1a hash of the key). When a key was put into cooldown then removed from settings then re-added later, the old cooldown entry would still block it. Fix: New pruneDebridLinkRuntimeStateForKeys() function called from setSettings() removes cooldown entries for keys no longer in the active set. 4. providerFailures circuit-breaker map (download-manager.ts ~1990) Per-provider failure tracking with cooldownUntil. When a user removes a failing account and adds a new one of the same provider, the cooldown would carry over. Now setSettings() compares per-provider credentials and clears matching providerFailures entries when they change. Reproduction (now fixed): 1. Add Debrid-Link key A 2. Trigger a failure (e.g. invalid link) so A goes into cooldown 3. Remove A, add B in settings 4. Try a download immediately - previously the cooldown for A or the cached client with A's state could still prevent B from being used. After this fix B is used immediately. Tests: 201/201 (debrid + download-manager) green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/debrid.ts | 90 ++++++++++++++++++++++++++++++++++++ src/main/download-manager.ts | 31 +++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 094f786..3940613 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -59,6 +59,23 @@ export function resetDebridLinkRuntimeStateForTests(): void { debridLinkKeyRuntimeStatuses.clear(); } +/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no + * longer in the active key set. Called when settings change so removed + * keys don't keep blocking the system if they're re-added later. */ +export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set): void { + for (const keyId of debridLinkKeyCooldowns.keys()) { + if (!activeKeyIds.has(keyId)) { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); + } + } + for (const keyId of debridLinkKeyRuntimeStatuses.keys()) { + if (!activeKeyIds.has(keyId)) { + debridLinkKeyRuntimeStatuses.delete(keyId); + } + } +} + /** Periodic cleanup of expired Debrid-Link cooldown/runtime entries. * Without this, module-level Maps grow unbounded over 24/7 operation. * Removes entries whose cooldown expired more than 1 hour ago. */ @@ -1535,6 +1552,33 @@ class MegaDebridClient { /** Per-account pending connect deduplication: login (lowercase) → promise */ private static pendingConnects = new Map>(); + /** Clear cached tokens for accounts whose login is no longer in the given set. + * Called when settings change so removed accounts don't keep stale tokens. */ + public static pruneCachedTokensNotIn(activeLogins: Iterable): void { + const keep = new Set(); + for (const login of activeLogins) { + keep.add(String(login || "").toLowerCase()); + } + for (const login of MegaDebridClient.cachedApiTokens.keys()) { + if (!keep.has(login)) { + MegaDebridClient.cachedApiTokens.delete(login); + } + } + for (const login of MegaDebridClient.pendingConnects.keys()) { + if (!keep.has(login)) { + MegaDebridClient.pendingConnects.delete(login); + } + } + } + + /** Force-clear the API token for a specific login (e.g. when its password + * changes — same login, but cached token is now invalid for new password). */ + public static clearCachedApiToken(login: string): void { + const key = String(login || "").toLowerCase(); + MegaDebridClient.cachedApiTokens.delete(key); + MegaDebridClient.pendingConnects.delete(key); + } + public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { this.login = login; this.password = password; @@ -3065,7 +3109,53 @@ export class DebridService { } public setSettings(next: AppSettings): void { + const prev = this.settings; this.settings = cloneSettings(next); + + // Invalidate cached provider clients whose credentials/keys changed. + // Without this, switching API keys or session-cookie-bound accounts + // (LinkSnappy, Ddownload) would keep using the previous Client instance + // — which holds the OLD session cookies — until the app is restarted. + if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) { + this.cachedDebridLinkClient = null; + this.cachedDebridLinkKey = ""; + } + if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) { + this.cachedLinkSnappyClient = null; + this.cachedLinkSnappyKey = ""; + } + if (prev.ddownloadLogin !== next.ddownloadLogin || prev.ddownloadPassword !== next.ddownloadPassword) { + this.cachedDdownloadClient = null; + this.cachedDdownloadKey = ""; + } + + // Mega-Debrid token cache (static, module-level): tokens are keyed by + // login (lowercase). When credentials change, drop tokens for logins + // that are no longer in the active account list, AND force-clear any + // login whose password changed. Otherwise stale tokens linger up to + // 20 minutes and the new credentials won't be tried until the cached + // token starts returning 401/403. + const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || ""); + const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || ""); + const nextLogins = new Set(); + const nextPasswordByLogin = new Map(); + for (const acc of nextAccounts) { + nextLogins.add(acc.login.toLowerCase()); + nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password); + } + // Drop tokens for logins no longer present + MegaDebridClient.pruneCachedTokensNotIn(nextLogins); + // For logins still present but with a changed password, force-clear the token + for (const prevAcc of prevAccounts) { + const loginKey = prevAcc.login.toLowerCase(); + if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) { + MegaDebridClient.clearCachedApiToken(prevAcc.login); + } + } + // Also prune module-level Debrid-Link cooldowns for keys that no longer exist — + // otherwise a key removed and re-added later would still show its old cooldown. + const nextDebridLinkKeyIds = new Set(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); + pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); } private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 649801d..16c3945 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1987,6 +1987,37 @@ export class DownloadManager extends EventEmitter { this.hybridFailedArchives.clear(); } + // When account credentials change, clear the provider-failure circuit-breaker + // for affected providers. Otherwise a freshly added account would inherit + // the cooldown that the previous (now removed) account had triggered, and + // the user would be confused why "their new account doesn't work right away". + const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [ + { prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] }, + { prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] }, + { prev: previous.bestDebridApiKey || "", next: next.bestDebridApiKey || "", providers: ["bestdebrid"] }, + { prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] }, + { prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] }, + { prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] }, + { prev: previous.megaCredentials + "|" + previous.megaPassword, next: next.megaCredentials + "|" + next.megaPassword, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] } + ]; + let clearedProviderFailures = 0; + for (const change of credChanges) { + if (change.prev === change.next) continue; + for (const provider of change.providers) { + // Provider failure keys are sometimes "provider" alone, sometimes "provider:hoster". + // Clear all entries that start with the provider name. + for (const key of [...this.providerFailures.keys()]) { + if (key === provider || key.startsWith(`${provider}:`)) { + this.providerFailures.delete(key); + clearedProviderFailures += 1; + } + } + } + } + if (clearedProviderFailures > 0) { + logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); + } + this.resolveExistingQueuedOpaqueFilenames(); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); if (next.completedCleanupPolicy !== "never") {