From d2a1b831a0672943a3a0b2396dcd3304c0781000 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 17 Jun 2026 10:58:18 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20festes=20Retry-Limit=20wird=20jetzt=20ha?= =?UTF-8?q?rt=20eingehalten=20=E2=80=94=20kein=20Endlos-Shelve-Loop=20mehr?= =?UTF-8?q?=20(Runde-6-Audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei einem FESTEN retryLimit (>=5) sind alle drei Per-Klasse-Retry-Caps (stall/unrestrict/generic) gleich dem Limit. Die beiden "15-Fehler"-Shelve-Zweige (download-manager.ts stall-Pfad + error-Pfad) feuern aber auf der hartkodierten Summe >=15 OBERHALB der Per-Klasse-Terminal-Fail-Pruefungen und HALBIEREN danach alle drei Zaehler. Dadurch wurden die Per-Klasse-Caps nie gleichzeitig ueberschritten: der Eintrag erreichte nie status="failed", schliff endlos im 90s-Takt, item.retries wuchs weit ueber das konfigurierte Limit, der Download-Slot blieb dauerhaft belegt, und das wiederholte providerFailures.delete besiegte immer wieder den Provider-Circuit-Breaker → Hoster-Hammering. resetStaleRetryState konnte nicht eingreifen, weil der <=90s-Re-Admit item.updatedAt frisch haelt (nie 10min stale). Der Auslieferungs-Default retryLimit=0 (= unendlich) ist NICHT betroffen — dort ist der 15-Fehler-Shelve der gewollte Dauer-Park-Backstop und es gibt kein endliches Budget zu verletzen. Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry eine harte Obergrenze — `if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit)` failt den Eintrag terminal (status="failed", recordRunOutcome, retryStateByItem.delete) statt ihn neu zu queuen. Der ∞-Modus (retryLimit<=0) ueberspringt die Grenze und bleibt unveraendert. Runde-6-Audit (adversarisch verifiziert) hat ausserdem den deferred-LOW-Cluster abgeschlossen: #10 HTTP416-shared-counter, #11 fresh-retry-preempt und #13 queue-wait→elapsedMs sind BENIGN; #12 war genau dieser Bug. Rot-bewiesen: Single-Pass-Integrationstest seedet genericErrorRetries=15 vor und injiziert einen generischen Fehler bei retryLimit=5 → ohne Fix requeued der Shelve (status bleibt nicht "failed"), mit Fix wird terminal gefailt. Volle Suite 882 gruen, tsc unveraendert. --- src/main/download-manager.ts | 28 ++++++++++++++++++++++ tasks/audit-loop.md | 20 ++++++++++++++++ tasks/entscheidungen-offen.md | 11 +++++++++ tests/download-manager.test.ts | 44 ++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 318c709..569a7f1 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -9165,6 +9165,20 @@ export class DownloadManager extends EventEmitter { const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); if (totalFailures >= 15) { item.retries += 1; + if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) { + item.status = "failed"; + item.lastError = stallErrorText || "Wiederholt fehlgeschlagen"; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "failed"); + this.retryStateByItem.delete(item.id); + const shelveFailPkgStall = this.session.packages[item.packageId]; + if (shelveFailPkgStall) this.refreshPackageStatus(shelveFailPkgStall); + this.persistSoon(); + this.emitState(); + return; + } active.stallRetries = Math.floor((active.stallRetries || 0) / 2); active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); @@ -9351,6 +9365,20 @@ export class DownloadManager extends EventEmitter { const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); if (totalNonStallFailures >= 15) { item.retries += 1; + if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) { + item.status = "failed"; + item.lastError = errorText || "Wiederholt fehlgeschlagen"; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "failed"); + this.retryStateByItem.delete(item.id); + const shelveFailPkgErr = this.session.packages[item.packageId]; + if (shelveFailPkgErr) this.refreshPackageStatus(shelveFailPkgErr); + this.persistSoon(); + this.emitState(); + return; + } active.stallRetries = Math.floor((active.stallRetries || 0) / 2); active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); diff --git a/tasks/audit-loop.md b/tasks/audit-loop.md index 36b9e6d..c9b4de6 100644 --- a/tasks/audit-loop.md +++ b/tasks/audit-loop.md @@ -161,6 +161,26 @@ RELEASE BEWUSST AUFGESCHOBEN: beide MED/LOW (kein HIGH wie 216/217) → buendeln Retries ueberlastet → kein Sign-off fuer den Mega-Park-Hot-Path-Klassifikations-Change. Fixes sind durable. Plan: Runde 6 dazu buendeln, Advisor-Sign-off abwarten, dann v1.7.218 als Roll-up. +## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma +3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab. +- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse- + Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf + hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter → + Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries + waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den + Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt + frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop). + Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >= + configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass- + Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt + requeue). tsc=6. +- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries + eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans); + STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs + BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der + gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix + begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet. + ## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx 3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed. **Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und diff --git a/tasks/entscheidungen-offen.md b/tasks/entscheidungen-offen.md index c5cce8e..3cc53b2 100644 --- a/tasks/entscheidungen-offen.md +++ b/tasks/entscheidungen-offen.md @@ -63,6 +63,17 @@ ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration n ## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf) +- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit + (z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte + (mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein + Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler, + sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft + einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt + wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der + Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen. + Rot-bewiesener Test. + + - **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):** Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren, hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index a8fbb8f..171d092 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -257,6 +257,50 @@ describe("download manager", () => { expect((manager as any).findFallbackProviderNotInCooldown(item)).toBeNull(); }); + it("fails an item terminally instead of looping forever when the shelve fires under a finite retryLimit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shelve-loop-")); + tempDirs.push(root); + const storagePaths = createStoragePaths(path.join(root, "state")); + initPackageLogs(storagePaths.baseDir); + initItemLogs(storagePaths.baseDir); + + const session = emptySession(); + const packageId = "shelve-pkg"; + const itemId = "shelve-item"; + const outputDir = path.join(root, "downloads", "Shelve"); + const extractDir = path.join(root, "extract", "Shelve"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, name: "Shelve", outputDir, extractDir, + status: "downloading", itemIds: [itemId], cancelled: false, enabled: true, + createdAt: Date.now(), updatedAt: Date.now() + } as any; + session.items[itemId] = { + id: itemId, packageId, url: "https://hoster.example/flaky.bin", provider: "realdebrid", + status: "downloading", retries: 5, speedBps: 0, downloadedBytes: 0, totalBytes: null, + progressPercent: 0, fileName: "flaky.bin", targetPath: "", resumable: true, + attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now() + } as any; + + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", retryLimit: 5, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") }, + session, + storagePaths + ); + (manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 15, unrestrictRetries: 0 }); + (manager as any).debridService.unrestrictLink = async () => { throw new Error("Download-Backend Fehler xyz"); }; + + const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 }; + (manager as any).activeTasks.set(itemId, active); + + await (manager as any).processItem(active); + + expect(session.items[itemId].status).toBe("failed"); + expect(session.items[itemId].retries).toBeLessThan(20); + }); + it("keeps the quick post-process requeue once the final package items are finished", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-")); tempDirs.push(root);