Fix shelve mechanism: reset provider + circuit breaker, reduce pause to 90s

Shelve (15+ failures) now mimics manual stop/start behavior:
- Clears item.provider for fresh provider selection on retry
- Resets provider circuit breaker (providerFailures) for the old provider
- Reduces shelve duration from 5 min to 90s since the issue is stale
  provider state, not a timing problem (manual restart works instantly)

Also adds comprehensive session-load logging:
- Logs package/item count on every session file read
- Logs errors when session file parsing fails (was silent before)
- Safety net: if primary session is empty but backup has packages,
  automatically restores from backup
- Logs shutdown save with package/item counts
- Logs DownloadManager init state and cleanup policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-07 17:55:56 +01:00
parent 0042ce0adb
commit e00c5b5344
2 changed files with 61 additions and 9 deletions

View File

@ -1067,6 +1067,7 @@ export class DownloadManager extends EventEmitter {
});
this.invalidateMegaSessionFn = options.invalidateMegaSession;
this.onHistoryEntryCallback = options.onHistoryEntry;
logger.info(`DownloadManager Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items, cleanupPolicy=${this.settings.completedCleanupPolicy}`);
this.applyOnStartCleanupPolicy();
this.normalizeSessionStatuses();
void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`));
@ -3491,8 +3492,13 @@ export class DownloadManager extends EventEmitter {
// Persist synchronously on shutdown to guarantee data is written before process exits
// Skip if a backup was just imported — the restored session on disk must not be overwritten
if (!this.skipShutdownPersist && !this.blockAllPersistence) {
const pkgCount = Object.keys(this.session.packages).length;
const itemCount = Object.keys(this.session.items).length;
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
saveSession(this.storagePaths, this.session);
saveSettings(this.storagePaths, this.settings);
} else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
}
this.emitState(true);
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
@ -3669,6 +3675,7 @@ export class DownloadManager extends EventEmitter {
if (this.settings.completedCleanupPolicy !== "on_start") {
return;
}
logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items vor Bereinigung`);
for (const pkgId of [...this.session.packageOrder]) {
const pkg = this.session.packages[pkgId];
if (!pkg) {
@ -3691,14 +3698,19 @@ export class DownloadManager extends EventEmitter {
return true;
});
if (pkg.itemIds.length === 0) {
logger.info(`applyOnStartCleanupPolicy: entferne Paket ${pkg.name} (${completedItemIds.length} completed Items)`);
this.removePackageFromSession(pkgId, completedItemIds);
} else {
if (completedItemIds.length > 0) {
logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`);
}
for (const itemId of completedItemIds) {
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
}
}
logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items nach Bereinigung`);
}
private applyRetroactiveCleanupPolicy(): void {
@ -5526,15 +5538,21 @@ export class DownloadManager extends EventEmitter {
const wasValidating = item.status === "validating";
active.stallRetries += 1;
logger.warn(`Stall erkannt: item=${item.fileName || item.id}, phase=${wasValidating ? "validating" : "downloading"}, retry=${active.stallRetries}/${retryDisplayLimit}, bytes=${item.downloadedBytes}, error=${stallErrorText || "none"}, provider=${item.provider || "?"}`);
// Shelve check: too many consecutive failures → long pause
// Shelve check: too many consecutive failures → pause with fresh provider (like manual reset)
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
if (totalFailures >= 15) {
item.retries += 1;
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
logger.warn(`Item shelved: ${item.fileName || item.id}, totalFailures=${totalFailures}`);
this.queueRetry(item, active, 300000, `Viele Fehler (${totalFailures}x), Pause 5 min`);
const oldProvider = item.provider;
item.provider = null; // fresh provider selection after shelve (like manual reset)
if (oldProvider) {
this.providerFailures.delete(oldProvider); // clear circuit breaker for old provider
}
const shelveDurationMs = 90000; // 90s instead of 5 min — manual restart works immediately, so no need for long pause
logger.warn(`Item shelved: ${item.fileName || item.id}, totalFailures=${totalFailures}, oldProvider=${oldProvider || "?"}, provider+circuit-breaker reset, pause=${shelveDurationMs}ms`);
this.queueRetry(item, active, shelveDurationMs, `Viele Fehler (${totalFailures}x), Pause ${Math.ceil(shelveDurationMs / 1000)}s`);
item.lastError = stallErrorText;
this.persistSoon();
this.emitState();
@ -5651,8 +5669,14 @@ export class DownloadManager extends EventEmitter {
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
logger.warn(`Item shelved (error path): ${item.fileName || item.id}, totalFailures=${totalNonStallFailures}, error=${errorText}`);
this.queueRetry(item, active, 300000, `Viele Fehler (${totalNonStallFailures}x), Pause 5 min`);
const oldProvider = item.provider;
item.provider = null; // fresh provider selection after shelve (like manual reset)
if (oldProvider) {
this.providerFailures.delete(oldProvider); // clear circuit breaker for old provider
}
const shelveDurationMs = 90000;
logger.warn(`Item shelved (error path): ${item.fileName || item.id}, totalFailures=${totalNonStallFailures}, error=${errorText}, oldProvider=${oldProvider || "?"}, provider+circuit-breaker reset, pause=${shelveDurationMs}ms`);
this.queueRetry(item, active, shelveDurationMs, `Viele Fehler (${totalNonStallFailures}x), Pause ${Math.ceil(shelveDurationMs / 1000)}s`);
item.lastError = errorText;
this.persistSoon();
this.emitState();

View File

@ -702,9 +702,15 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
function readSessionFile(filePath: string): SessionState | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
} catch {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items, ${raw.length} Bytes)`);
return session;
} catch (error) {
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
return null;
}
}
@ -794,15 +800,37 @@ export function emptySession(): SessionState {
export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.sessionFile)) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
}
const primary = readSessionFile(paths.sessionFile);
const backupFile = sessionBackupPath(paths.sessionFile);
// If primary loaded but is empty, check if backup has packages (safety net)
if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.packages).length;
if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() });
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
// ignore restore write failure
}
return backup;
}
}
}
return primary;
}
const backupFile = sessionBackupPath(paths.sessionFile);
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet");