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) <noreply@anthropic.com>
This commit is contained in:
parent
04413599d8
commit
c3590f08fc
@ -59,6 +59,23 @@ export function resetDebridLinkRuntimeStateForTests(): void {
|
|||||||
debridLinkKeyRuntimeStatuses.clear();
|
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<string>): 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.
|
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
|
||||||
* Without this, module-level Maps grow unbounded over 24/7 operation.
|
* Without this, module-level Maps grow unbounded over 24/7 operation.
|
||||||
* Removes entries whose cooldown expired more than 1 hour ago. */
|
* Removes entries whose cooldown expired more than 1 hour ago. */
|
||||||
@ -1535,6 +1552,33 @@ class MegaDebridClient {
|
|||||||
/** Per-account pending connect deduplication: login (lowercase) → promise */
|
/** Per-account pending connect deduplication: login (lowercase) → promise */
|
||||||
private static pendingConnects = new Map<string, Promise<string | null>>();
|
private static pendingConnects = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/** 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<string>): void {
|
||||||
|
const keep = new Set<string>();
|
||||||
|
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) {
|
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
|
||||||
this.login = login;
|
this.login = login;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
@ -3065,7 +3109,53 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setSettings(next: AppSettings): void {
|
public setSettings(next: AppSettings): void {
|
||||||
|
const prev = this.settings;
|
||||||
this.settings = cloneSettings(next);
|
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<string>();
|
||||||
|
const nextPasswordByLogin = new Map<string, string>();
|
||||||
|
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<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
||||||
|
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
||||||
|
|||||||
@ -1987,6 +1987,37 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.hybridFailedArchives.clear();
|
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();
|
this.resolveExistingQueuedOpaqueFilenames();
|
||||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
||||||
if (next.completedCleanupPolicy !== "never") {
|
if (next.completedCleanupPolicy !== "never") {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user