Fix: festes Retry-Limit wird jetzt hart eingehalten — kein Endlos-Shelve-Loop mehr (Runde-6-Audit)

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.
This commit is contained in:
Sucukdeluxe 2026-06-17 10:58:18 +02:00
parent e49ed1ada0
commit d2a1b831a0
4 changed files with 103 additions and 0 deletions

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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);