Optimize proxy scheduler throughput and fairness
This commit is contained in:
@@ -4,6 +4,19 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Proxy throughput and fairness
|
||||
|
||||
- Replace one long range per connection with a rolling queue of smaller chunks so fast proxies continue working instead of waiting for the slowest tail segment.
|
||||
- Split the process-wide connection budget dynamically and fairly between active segmented downloads; a total of 20 becomes 10/10 for two files and 5 each for four files, then returns freed capacity immediately.
|
||||
- Rank proxies by measured throughput and failures, periodically explore unused entries, cool down failed entries, and keep the fixed API proxy reserved.
|
||||
- Reduce the effective global connection budget in steps of two after origin HTTP 429 or 503 responses and restore capacity gradually after stable successful transfers.
|
||||
- Use 20 as the default global proxy connection budget for new settings while preserving existing saved values and the 2-to-32 range.
|
||||
|
||||
### Download presentation
|
||||
|
||||
- Calculate package progress from downloaded and total bytes when sizes are known so the percentage matches the package size meter for differently sized files.
|
||||
- Keep the existing extraction progress split and all extraction behavior unchanged.
|
||||
|
||||
## [2.0.76] - 2026-08-31
|
||||
|
||||
### Proxy concurrency hotfix
|
||||
|
||||
+22
-7
@@ -10,10 +10,10 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
|
||||
- Verifiziert am: 31. August 2026, Europe/Berlin
|
||||
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
||||
- Arbeitsbranch: `release/v2.0.76`
|
||||
- Quellbasis: `release/v2.0.75`
|
||||
- Arbeitsbranch: `optimize/proxy-scheduler-v2.0.77`
|
||||
- Quellbasis: `release/v2.0.76`
|
||||
- Release-Tag: `v2.0.76`
|
||||
- Baseline-Commit: `741194e48f7dc4d1af4aed12430dac882f91273f`
|
||||
- Baseline-Commit: `ecfc630e1e68c4067f3a019e68d38abb07d92a14`
|
||||
- Paketversion: `2.0.76`
|
||||
- Letztes Release: `Multi-Debrid-Downloader v2.0.76`, veröffentlicht am 31. August 2026 auf GitHub und Forgejo
|
||||
- Runtime-Voraussetzung: Node.js `>=20`; lokal verifiziert mit Node.js `24.19.0` und npm `11.17.0`
|
||||
@@ -23,7 +23,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- GitHub `main` steht auf `4d5161b` (`v2.0.35`) und ist gegenüber `release/v2.0.74` mit 7 zu 136 Commits auseinanderentwickelt.
|
||||
- Forgejo `main` steht auf `a8c4dcc` (`v1.7.232`) und ist noch deutlich älter.
|
||||
- GitHub und Forgejo zeigen für `release/v2.0.74` beide exakt auf `c1e1095`.
|
||||
- Neue Arbeit muss bis zu einer ausdrücklich geplanten Branch-Bereinigung von `release/v2.0.76` beziehungsweise einem davon abgeleiteten Arbeitsbranch ausgehen.
|
||||
- Neue Arbeit muss bis zu einer ausdrücklich geplanten Branch-Bereinigung von `release/v2.0.76` beziehungsweise einem davon abgeleiteten Arbeitsbranch ausgehen. Der aktuelle Optimierungsstand ist noch nicht veröffentlicht.
|
||||
|
||||
## Remotes
|
||||
|
||||
@@ -67,7 +67,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- Provider-Reihenfolge, Hoster-Overrides, Kontostatus, Tageslimits, Cooldowns und optionale Fallbacks bestimmen das Routing.
|
||||
- Real-Debrid und Debrid-Link unterstützen Kontopools; Auswahl und Rotation berücksichtigen Aktivität, Fairness, Limits und Fehlerklassen.
|
||||
- Item-Pipeline: Vorprüfung und Recovery, Provider-Auswahl, Unrestrict mit Timeout, sichere Zielpfadreservierung, Speicherplatzreservierung, HTTP-Download mit Range-Resume, optionale Integritätsprüfung, Abschluss und Paket-Postprocessing.
|
||||
- Der optionale Proxy-only-Modus leitet sämtliche Main-Prozess-API-, Link-Auflösungs- und normalen Download-Requests über einen fest ausgewählten authentifizierten HTTP-CONNECT-Proxy. Electron-Web-Logins und deren Session-Fetches erhalten denselben festen Proxy. Neue Dateien ab 8 MiB können zusätzlich in 2 bis 32 exakten HTTP-Range-Bereichen parallel über die Proxy-Liste geladen werden. Seit `v2.0.76` ist dieser Wert ein gemeinsames Prozesslimit für alle gleichzeitigen Segmentdownloads, und der feste API-Proxy bleibt aus dem Segmentpool heraus.
|
||||
- Der optionale Proxy-only-Modus leitet sämtliche Main-Prozess-API-, Link-Auflösungs- und normalen Download-Requests über einen fest ausgewählten authentifizierten HTTP-CONNECT-Proxy. Electron-Web-Logins und deren Session-Fetches erhalten denselben festen Proxy. Neue Dateien ab 8 MiB können zusätzlich über exakte HTTP-Range-Chunks parallel über die Proxy-Liste geladen werden. Seit `v2.0.76` ist der eingestellte Wert ein gemeinsames Prozesslimit für alle gleichzeitigen Segmentdownloads, und der feste API-Proxy bleibt aus dem Segmentpool heraus. Der unveröffentlichte Arbeitsstand verteilt dieses Limit fair, vergibt kleine Chunks rollierend und bewertet Proxys nach gemessener Leistung und Fehlern.
|
||||
- Download-Retries unterscheiden Netzwerk-, Range-, Hoster-, Provider-, Konto-, Quota-, Disk- und permanente Linkfehler. Stop, Pause, Shutdown und Neustart besitzen getrennte Park- und Abbruchpfade.
|
||||
- Die Queue priorisiert hoch vor normal vor niedrig, beachtet globale und providerbezogene Parallelitätsgrenzen und schützt sich mit Scheduler-Generation, Heartbeat und Stall-Watchdogs gegen veraltete Tasks.
|
||||
|
||||
@@ -128,6 +128,16 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- HTTP-Antworten des eigentlichen Downloadservers werden getrennt von Proxy-Verbindungsfehlern erfasst. Insbesondere erscheint ein über einen erreichbaren Proxy empfangener HTTP 503 mit `originHttpStatus=503` im Item-Log und nicht mehr irreführend als „Proxys nicht erreichbar“.
|
||||
- Die Entpacklogik und ihre Produktionspfade wurden nicht verändert. Eine Serverinstallation oder ein produktiver Neustart ist nicht Bestandteil dieses Releases.
|
||||
|
||||
## Unveröffentlichte Änderungen für v2.0.77
|
||||
|
||||
- Der globale Proxy-Scheduler registriert jeden laufenden Segmentdownload als eigene Gruppe und berechnet den fairen Anteil bei jeder Vergabe neu. Bei einem Gesamtlimit von 20 erhalten zwei aktive Dateien höchstens je 10 gleichzeitige Verbindungen, vier Dateien höchstens je 5. Sobald eine Datei endet, steht ihr Anteil den verbleibenden Dateien wieder zur Verfügung.
|
||||
- Dateien werden nicht mehr in genau einen langen Bereich pro Verbindung geteilt. Eine gemeinsame rollierende Warteschlange erzeugt typischerweise 8 bis 16 MiB große Chunks; schnelle Proxys übernehmen dadurch fortlaufend weitere Bereiche, während ein langsamer Proxy nur seinen aktuellen Chunk verzögert. Die Chunk-Anzahl bleibt bei sehr großen Dateien begrenzt.
|
||||
- Erfolgreiche Chunk-Übertragungen aktualisieren eine geglättete Durchsatzbewertung. Fehler senken die Bewertung und setzen einen Cooldown; ungetestete Listeneinträge werden kontrolliert weiter erkundet. Ein Proxy wird weiterhin nie gleichzeitig doppelt vergeben, und der feste API-Proxy bleibt ausgeschlossen.
|
||||
- Origin-Antworten HTTP 429 und 503 senken das effektive Prozesslimit mit Entprellung um jeweils zwei Verbindungen. Nach ausreichend vielen stabilen Chunk-Erfolgen wird die Kapazität schrittweise bis zum konfigurierten Wert wiederhergestellt.
|
||||
- Der Standardwert für neue oder noch nicht gesetzte Konfigurationen ist 20 Gesamtverbindungen. Bereits gespeicherte Werte bleiben unverändert; erlaubt sind weiterhin 2 bis 32.
|
||||
- Der Paketfortschritt verwendet bei bekannten Größen dieselben heruntergeladenen und gesamten Bytes wie die Größenanzeige. Dadurch stimmen Prozentwert und `heruntergeladen / gesamt` auch bei unterschiedlich großen Dateien überein. Wenn Größen vollständig fehlen, bleibt die bisherige dateibasierte Berechnung als Rückfall erhalten.
|
||||
- Die Entpacklogik und ihre Produktionspfade wurden nicht verändert. Der Arbeitsstand wurde noch nicht veröffentlicht, installiert oder produktiv neu gestartet.
|
||||
|
||||
## Start-, Build- und Testbefehle
|
||||
|
||||
```powershell
|
||||
@@ -159,6 +169,11 @@ npm exec -- tsc --noEmit
|
||||
|
||||
## Verifizierungen vom 31. August 2026
|
||||
|
||||
- Proxy-Scheduler-Regressionen für den unveröffentlichten `v2.0.77`-Stand: 11 von 11 erfolgreich. Abgedeckt sind 32 rollierend vergebene Chunks, deutlich häufigere Chunk-Übernahme durch den schnellen von zwei unterschiedlich verzögerten Proxys, zwei überlappende Downloads mit je höchstens der Hälfte eines gemeinsamen Viererlimits, Ausschluss des festen API-Proxys, Ersatz ausgefallener Proxys, exakte Dateiinhalte und Fortschrittsrückrechnung sowie eine erfolgreiche Fortsetzung nach transientem Origin-HTTP-503 mit Reduktion neuer paralleler Requests von 6 auf höchstens 4.
|
||||
- Paketfortschritt, Einstellungen und Proxy-Scheduler gezielt: 232 von 232 Tests erfolgreich. Ein Paket mit 2,25 von 18,60 Größeneinheiten zeigt nun 12 Prozent statt einer dateibasierten Abweichung.
|
||||
- Storage einschließlich neuem Standardwert 20: 126 von 126 Tests erfolgreich.
|
||||
- Finaler vollständiger Client-Lauf ohne die zwei lokal nicht ausführbaren Symlink-Fixtures: 140 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.656 Tests erfolgreich und 4 übersprungen. Die übrigen 22 Public-Release-Metadatentests liefen separat erfolgreich; nur die zwei Windows-Symlink-Fixtures wurden übersprungen. Alle 84 Entpacktests sind unverändert grün.
|
||||
- Für den unveröffentlichten Optimierungsstand erfolgreich: TypeScript, vollständiger Main-/Renderer-Build, Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung zum rund 576 KiB großen Renderer-Chunk bleibt bestehen.
|
||||
- Proxy-Parser gegen die bereitgestellte externe Liste geprüft: 1.000 von 1.000 Einträgen gültig; dabei wurden weder Proxy-Verbindungen aufgebaut noch Listeneinträge ausgegeben.
|
||||
- Proxy-Segmenttests für `v2.0.76`: 9 von 9 erfolgreich. Abgedeckt sind die feste Auswahl nach gültigem 1-basiertem Listeneintrag, vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Segment-Proxys, zwei gleichzeitige Dateien unter einem gemeinsamen Verbindungslimit ohne gleichzeitige Doppelvergabe und ohne Nutzung des reservierten API-Proxys, getrennte HTTP-503-Diagnose aus Probe und Segment, sauberer Einzel-Proxy-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung.
|
||||
- Vollständiger Client-Lauf nach dem Proxy-Hotfix ohne den unter Windows nicht ausführbaren Symlink-Metadatentest: 140 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.653 Tests erfolgreich und 4 übersprungen. Darin sind alle 84 bestehenden Entpacktests unverändert grün.
|
||||
@@ -205,7 +220,7 @@ npm exec -- tsc --noEmit
|
||||
- Windows ohne Developer Mode beziehungsweise passende Berechtigung kann die zwei Symlink-basierten Public-Release-Metadaten-Tests nicht ausführen.
|
||||
- Proxy-Bündelung garantiert keine lineare Addition der Einzelgeschwindigkeiten. Ergebnis und Stabilität hängen unter anderem von Proxy-Latenz, Proxy-Bandbreite, Provider-/CDN-IP-Bindung, Range-Unterstützung, Zielserver-Limits, Dateigröße und lokaler Schreibgeschwindigkeit ab. Nach einem späten Segmentfehler kann vor dem festen Einzel-Proxy-Fallback zusätzlicher Providerverkehr angefallen sein.
|
||||
- Der feste API-Proxy wird absichtlich nicht automatisch rotiert, damit API- und Login-Sitzungen eine stabile Ausgangs-IP behalten. Bei Ausfall muss ein anderer gültiger Listeneintrag ausgewählt werden; bis dahin schlägt Proxy-only geschlossen fehl.
|
||||
- Die bereitgestellte Proxy-Liste wurde mit dem ausdrücklich bereitgestellten Real-Debrid-Testlink erfolgreich real geprüft. Die Messung gilt nur für diesen Link, diesen Zeitpunkt und einen parallelen Download. Das gemeinsame Limit ist mit zwei gleichzeitigen lokalen End-to-End-Downloads verifiziert; ein erneuter Mehrdatei-Test gegen Real-Debrid benötigt weiterhin eine installierte `v2.0.76`.
|
||||
- Die bereitgestellte Proxy-Liste wurde mit dem ausdrücklich bereitgestellten Real-Debrid-Testlink erfolgreich real geprüft. Die Messung gilt nur für diesen Link, diesen Zeitpunkt und einen parallelen Download. Der neue rollierende Scheduler ist mit lokalen End-to-End-Servern verifiziert; ein echter Mehrdatei-Test gegen Real-Debrid benötigt eine separat freigegebene Veröffentlichung und Installation des unveröffentlichten Stands.
|
||||
- Der Arbeitsordner enthält `&`; ohne PowerShell-7-Skriptshell können npm-`cmd`-Shims fehlschlagen.
|
||||
- Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository.
|
||||
- Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle.
|
||||
@@ -216,7 +231,7 @@ npm exec -- tsc --noEmit
|
||||
|
||||
## Nächste sinnvolle Schritte
|
||||
|
||||
1. Nach einer getrennt freigegebenen Installation von `v2.0.76` mit Gesamtlimit 16 sowie `maxParallel=2` zwei echte Real-Debrid-Dateien prüfen. Erfolgsquote, HTTP-Status, Zusatzverkehr, Festplattenlast und Netto-MB/s beobachten, bevor mehr gleichzeitige Dateien zugelassen werden.
|
||||
1. Nach separater Release- und Installationsfreigabe den unveröffentlichten Optimierungsstand mit Gesamtlimit 20 und zwei echten parallelen Real-Debrid-Dateien prüfen. Erfolgsquote, HTTP-Status, Chunk-Wechsel, Zusatzverkehr, Festplattenlast und Netto-MB/s beobachten, bevor das Maximum von 24 erwogen wird.
|
||||
2. Nach einer getrennt freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
|
||||
3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
|
||||
4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
|
||||
|
||||
@@ -109,7 +109,7 @@ export function defaultSettings(): AppSettings {
|
||||
proxyDownloadEnabled: false,
|
||||
proxyListPath: "",
|
||||
proxyApiProxyIndex: 1,
|
||||
proxyConnectionsPerDownload: 16,
|
||||
proxyConnectionsPerDownload: 20,
|
||||
updateRepo: DEFAULT_UPDATE_REPO,
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: false,
|
||||
|
||||
@@ -11032,6 +11032,7 @@ export class DownloadManager extends EventEmitter {
|
||||
proxyListPath: this.settings.proxyListPath,
|
||||
connections: this.settings.proxyConnectionsPerDownload,
|
||||
totalConnectionLimit: this.settings.proxyConnectionsPerDownload,
|
||||
downloadId: active.itemId,
|
||||
reservedProxyIndex: this.settings.proxyApiProxyIndex,
|
||||
signal: active.abortController.signal,
|
||||
skipTlsVerify,
|
||||
|
||||
@@ -8,10 +8,13 @@ import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
|
||||
const DEFAULT_MIN_SEGMENT_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_MIN_SEGMENT_BYTES = 8 * 1024 * 1024;
|
||||
const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_CHUNKS_PER_CONNECTION = 8;
|
||||
const DEFAULT_PROXY_ATTEMPTS = 3;
|
||||
const PROXY_PROBE_END = 1;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const MAX_SEGMENTS = 4_096;
|
||||
const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024;
|
||||
const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]);
|
||||
|
||||
@@ -54,6 +57,7 @@ export interface ProxySegmentedDownloadOptions {
|
||||
proxyListPath: string;
|
||||
connections: number;
|
||||
totalConnectionLimit?: number;
|
||||
downloadId?: string;
|
||||
reservedProxyIndex?: number;
|
||||
signal: AbortSignal;
|
||||
skipTlsVerify?: boolean;
|
||||
@@ -92,13 +96,20 @@ class OriginHttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProxyLeaseOutcome {
|
||||
succeeded: boolean;
|
||||
transferredBytes?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ProxyLease {
|
||||
proxy: ProxyEndpoint;
|
||||
release: (succeeded: boolean) => void;
|
||||
release: (outcome: ProxyLeaseOutcome) => void;
|
||||
}
|
||||
|
||||
interface ProxyLeaseWaiter {
|
||||
poolKey: string;
|
||||
groupId: string;
|
||||
proxies: ProxyEndpoint[];
|
||||
excluded: ReadonlySet<string>;
|
||||
signal: AbortSignal;
|
||||
@@ -106,21 +117,70 @@ interface ProxyLeaseWaiter {
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface ProxyPerformance {
|
||||
ewmaBytesPerSecond: number;
|
||||
failures: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
class SharedProxyCoordinator {
|
||||
private connectionLimit = 16;
|
||||
private configuredConnectionLimit = 16;
|
||||
private adaptiveConnectionLimit = 16;
|
||||
private activeConnections = 0;
|
||||
private readonly activeProxies = new Set<string>();
|
||||
private readonly activeConnectionsByGroup = new Map<string, number>();
|
||||
private readonly registeredGroups = new Map<string, number>();
|
||||
private readonly cooldownUntil = new Map<string, number>();
|
||||
private readonly cursors = new Map<string, number>();
|
||||
private readonly performance = new Map<string, ProxyPerformance>();
|
||||
private readonly waiters: ProxyLeaseWaiter[] = [];
|
||||
private acquisitionCount = 0;
|
||||
private lastThrottleAt = 0;
|
||||
private successfulTransfersSinceThrottle = 0;
|
||||
|
||||
public setConnectionLimit(limit: number): void {
|
||||
this.connectionLimit = Math.max(2, Math.min(32, Math.floor(limit || 16)));
|
||||
const normalized = Math.max(2, Math.min(32, Math.floor(limit || 16)));
|
||||
if (normalized !== this.configuredConnectionLimit) {
|
||||
this.configuredConnectionLimit = normalized;
|
||||
this.adaptiveConnectionLimit = normalized;
|
||||
this.lastThrottleAt = 0;
|
||||
this.successfulTransfersSinceThrottle = 0;
|
||||
} else {
|
||||
this.adaptiveConnectionLimit = Math.min(this.adaptiveConnectionLimit, normalized);
|
||||
}
|
||||
this.drain();
|
||||
}
|
||||
|
||||
public registerGroup(groupId: string): () => void {
|
||||
this.registeredGroups.set(groupId, (this.registeredGroups.get(groupId) || 0) + 1);
|
||||
this.drain();
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = Math.max(0, (this.registeredGroups.get(groupId) || 1) - 1);
|
||||
if (remaining > 0) {
|
||||
this.registeredGroups.set(groupId, remaining);
|
||||
} else {
|
||||
this.registeredGroups.delete(groupId);
|
||||
}
|
||||
this.drain();
|
||||
};
|
||||
}
|
||||
|
||||
public reportOriginStatus(statusCode: number): void {
|
||||
if (statusCode !== 429 && statusCode !== 503) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastThrottleAt < 1_500) return;
|
||||
this.lastThrottleAt = now;
|
||||
this.successfulTransfersSinceThrottle = 0;
|
||||
this.adaptiveConnectionLimit = Math.max(2, this.adaptiveConnectionLimit - 2);
|
||||
this.drain();
|
||||
}
|
||||
|
||||
public acquire(
|
||||
poolKey: string,
|
||||
groupId: string,
|
||||
proxies: ProxyEndpoint[],
|
||||
excluded: ReadonlySet<string>,
|
||||
signal: AbortSignal
|
||||
@@ -132,7 +192,7 @@ class SharedProxyCoordinator {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise<ProxyLease | null>((resolve, reject) => {
|
||||
const waiter: ProxyLeaseWaiter = { poolKey, proxies, excluded, signal, resolve, reject };
|
||||
const waiter: ProxyLeaseWaiter = { poolKey, groupId, proxies, excluded, signal, resolve, reject };
|
||||
const onAbort = (): void => {
|
||||
const index = this.waiters.indexOf(waiter);
|
||||
if (index >= 0) {
|
||||
@@ -157,12 +217,18 @@ class SharedProxyCoordinator {
|
||||
}
|
||||
|
||||
private selectProxy(waiter: ProxyLeaseWaiter): ProxyEndpoint | null {
|
||||
if (this.activeConnections >= this.connectionLimit) {
|
||||
if (this.activeConnections >= this.adaptiveConnectionLimit) {
|
||||
return null;
|
||||
}
|
||||
const groupCount = Math.max(1, this.registeredGroups.size);
|
||||
const groupLimit = Math.max(1, Math.ceil(this.adaptiveConnectionLimit / groupCount));
|
||||
if ((this.activeConnectionsByGroup.get(waiter.groupId) || 0) >= groupLimit) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const start = (this.cursors.get(waiter.poolKey) || 0) % waiter.proxies.length;
|
||||
const select = (allowCooldown: boolean): ProxyEndpoint | null => {
|
||||
const candidates: Array<{ proxy: ProxyEndpoint; index: number; key: string }> = [];
|
||||
for (let offset = 0; offset < waiter.proxies.length; offset += 1) {
|
||||
const index = (start + offset) % waiter.proxies.length;
|
||||
const proxy = waiter.proxies[index];
|
||||
@@ -172,39 +238,93 @@ class SharedProxyCoordinator {
|
||||
|| (!allowCooldown && (this.cooldownUntil.get(key) || 0) > now)) {
|
||||
continue;
|
||||
}
|
||||
this.cursors.set(waiter.poolKey, (index + 1) % waiter.proxies.length);
|
||||
return proxy;
|
||||
candidates.push({ proxy, index, key });
|
||||
}
|
||||
return null;
|
||||
if (candidates.length === 0) return null;
|
||||
this.acquisitionCount += 1;
|
||||
const unknown = candidates.filter((candidate) => !this.performance.has(candidate.key));
|
||||
const exploreUnknown = unknown.length > 0 && (unknown.length === candidates.length || this.acquisitionCount % 8 === 0);
|
||||
let selected = exploreUnknown ? unknown[0] : candidates[0];
|
||||
if (!exploreUnknown) {
|
||||
let selectedScore = Number.NEGATIVE_INFINITY;
|
||||
for (const candidate of candidates) {
|
||||
const metric = this.performance.get(candidate.key);
|
||||
const score = metric
|
||||
? (metric.ewmaBytesPerSecond / (1 + metric.failures)) - (metric.failures * 1024 * 1024)
|
||||
: 0;
|
||||
if (score > selectedScore) {
|
||||
selected = candidate;
|
||||
selectedScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.cursors.set(waiter.poolKey, (selected.index + 1) % waiter.proxies.length);
|
||||
return selected.proxy;
|
||||
};
|
||||
return select(false) || select(true);
|
||||
const available = select(false);
|
||||
if (available) return available;
|
||||
const poolHasActiveProxy = waiter.proxies.some((proxy) => this.activeProxies.has(proxyEndpointKey(proxy)));
|
||||
return poolHasActiveProxy ? null : select(true);
|
||||
}
|
||||
|
||||
private createLease(proxy: ProxyEndpoint): ProxyLease {
|
||||
private createLease(proxy: ProxyEndpoint, groupId: string): ProxyLease {
|
||||
const key = proxyEndpointKey(proxy);
|
||||
this.activeConnections += 1;
|
||||
this.activeProxies.add(key);
|
||||
this.activeConnectionsByGroup.set(groupId, (this.activeConnectionsByGroup.get(groupId) || 0) + 1);
|
||||
let released = false;
|
||||
return {
|
||||
proxy,
|
||||
release: (succeeded) => {
|
||||
release: (outcome) => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
||||
this.activeProxies.delete(key);
|
||||
if (succeeded) {
|
||||
const groupConnections = Math.max(0, (this.activeConnectionsByGroup.get(groupId) || 1) - 1);
|
||||
if (groupConnections > 0) {
|
||||
this.activeConnectionsByGroup.set(groupId, groupConnections);
|
||||
} else {
|
||||
this.activeConnectionsByGroup.delete(groupId);
|
||||
}
|
||||
if (outcome.succeeded) {
|
||||
this.cooldownUntil.delete(key);
|
||||
if ((outcome.transferredBytes || 0) > 0 && (outcome.durationMs || 0) > 0) {
|
||||
const sample = ((outcome.transferredBytes || 0) * 1_000) / Math.max(1, outcome.durationMs || 1);
|
||||
const previous = this.performance.get(key);
|
||||
this.performance.set(key, {
|
||||
ewmaBytesPerSecond: previous && previous.samples > 0
|
||||
? (previous.ewmaBytesPerSecond * 0.7) + (sample * 0.3)
|
||||
: sample,
|
||||
failures: Math.max(0, (previous?.failures || 0) - 1),
|
||||
samples: (previous?.samples || 0) + 1
|
||||
});
|
||||
this.restoreAdaptiveCapacity();
|
||||
}
|
||||
} else {
|
||||
this.cooldownUntil.set(key, Date.now() + 30_000);
|
||||
const previous = this.performance.get(key);
|
||||
this.performance.set(key, {
|
||||
ewmaBytesPerSecond: (previous?.ewmaBytesPerSecond || 0) * 0.5,
|
||||
failures: (previous?.failures || 0) + 1,
|
||||
samples: previous?.samples || 0
|
||||
});
|
||||
}
|
||||
this.drain();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private restoreAdaptiveCapacity(): void {
|
||||
if (this.adaptiveConnectionLimit >= this.configuredConnectionLimit) return;
|
||||
this.successfulTransfersSinceThrottle += 1;
|
||||
if (Date.now() - this.lastThrottleAt < 5_000 || this.successfulTransfersSinceThrottle < 24) return;
|
||||
this.adaptiveConnectionLimit = Math.min(this.configuredConnectionLimit, this.adaptiveConnectionLimit + 1);
|
||||
this.successfulTransfersSinceThrottle = 0;
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
let granted = true;
|
||||
while (granted && this.activeConnections < this.connectionLimit) {
|
||||
while (granted && this.activeConnections < this.adaptiveConnectionLimit) {
|
||||
granted = false;
|
||||
for (let index = 0; index < this.waiters.length; index += 1) {
|
||||
const waiter = this.waiters[index];
|
||||
@@ -219,7 +339,7 @@ class SharedProxyCoordinator {
|
||||
continue;
|
||||
}
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.resolve(this.createLease(proxy));
|
||||
waiter.resolve(this.createLease(proxy, waiter.groupId));
|
||||
granted = true;
|
||||
break;
|
||||
}
|
||||
@@ -512,6 +632,7 @@ async function consumeProbe(response: IncomingMessage, expectedBytes: number, on
|
||||
async function probeRangeSupport(
|
||||
target: URL,
|
||||
poolKey: string,
|
||||
groupId: string,
|
||||
proxies: ProxyEndpoint[],
|
||||
signal: AbortSignal,
|
||||
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs" | "proxyAttempts">>,
|
||||
@@ -529,7 +650,7 @@ async function probeRangeSupport(
|
||||
let opened: RangeResponse | null = null;
|
||||
let lease: ProxyLease | null = null;
|
||||
try {
|
||||
lease = await sharedProxyCoordinator.acquire(poolKey, proxies, excluded, signal);
|
||||
lease = await sharedProxyCoordinator.acquire(poolKey, groupId, proxies, excluded, signal);
|
||||
if (!lease) break;
|
||||
excluded.add(proxyEndpointKey(lease.proxy));
|
||||
opened = await openRangeResponse(
|
||||
@@ -547,14 +668,14 @@ async function probeRangeSupport(
|
||||
lastHttpStatus = status;
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(true);
|
||||
lease.release({ succeeded: true });
|
||||
lease = null;
|
||||
continue;
|
||||
}
|
||||
if (status === 200) {
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(true);
|
||||
lease.release({ succeeded: true });
|
||||
lease = null;
|
||||
return { status: "unsupported" };
|
||||
}
|
||||
@@ -569,7 +690,7 @@ async function probeRangeSupport(
|
||||
|| (contentLength > 0 && contentLength !== expectedBytes)) {
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(false);
|
||||
lease.release({ succeeded: false });
|
||||
lease = null;
|
||||
continue;
|
||||
}
|
||||
@@ -577,14 +698,14 @@ async function probeRangeSupport(
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
if (received === expectedBytes) {
|
||||
lease.release(true);
|
||||
lease.release({ succeeded: true });
|
||||
lease = null;
|
||||
return { status: "ok", totalBytes: range.total };
|
||||
}
|
||||
} catch (error) {
|
||||
opened?.dispose();
|
||||
opened = null;
|
||||
lease?.release(false);
|
||||
lease?.release({ succeeded: signal.aborted });
|
||||
lease = null;
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
@@ -594,23 +715,28 @@ async function probeRangeSupport(
|
||||
}
|
||||
} finally {
|
||||
opened?.dispose();
|
||||
lease?.release(false);
|
||||
lease?.release({ succeeded: false });
|
||||
}
|
||||
}
|
||||
if (lastHttpStatus > 0) {
|
||||
sharedProxyCoordinator.reportOriginStatus(lastHttpStatus);
|
||||
return { status: "origin_error", httpStatus: lastHttpStatus };
|
||||
}
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
function buildSegments(totalBytes: number, count: number): Segment[] {
|
||||
const baseSize = Math.floor(totalBytes / count);
|
||||
const remainder = totalBytes % count;
|
||||
function buildSegments(totalBytes: number, connections: number, minSegmentBytes: number): Segment[] {
|
||||
const preferredChunkBytes = Math.max(
|
||||
minSegmentBytes,
|
||||
Math.min(DEFAULT_MAX_CHUNK_BYTES, Math.ceil(totalBytes / Math.max(1, connections * DEFAULT_CHUNKS_PER_CONNECTION)))
|
||||
);
|
||||
const chunkBytes = Math.max(preferredChunkBytes, Math.ceil(totalBytes / MAX_SEGMENTS));
|
||||
const count = Math.ceil(totalBytes / chunkBytes);
|
||||
let start = 0;
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const size = baseSize + (index < remainder ? 1 : 0);
|
||||
const segment = { index, start, end: start + size - 1 };
|
||||
start += size;
|
||||
const end = Math.min(totalBytes - 1, start + chunkBytes - 1);
|
||||
const segment = { index, start, end };
|
||||
start = end + 1;
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
@@ -736,13 +862,16 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
return { status: "fallback", reason: "proxy_unavailable" };
|
||||
}
|
||||
|
||||
const groupId = String(options.downloadId || options.targetPath || randomUUID());
|
||||
const unregisterGroup = sharedProxyCoordinator.registerGroup(groupId);
|
||||
try {
|
||||
const normalized = {
|
||||
skipTlsVerify: Boolean(options.skipTlsVerify),
|
||||
connectTimeoutMs: Math.max(1_000, Math.floor(options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)),
|
||||
idleTimeoutMs: Math.max(2_000, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS)),
|
||||
proxyAttempts: Math.max(1, Math.min(10, Math.floor(options.proxyAttempts ?? DEFAULT_PROXY_ATTEMPTS)))
|
||||
};
|
||||
const probe = await probeRangeSupport(target, poolKey, segmentProxies, options.signal, normalized, options.onTrafficBytes);
|
||||
const probe = await probeRangeSupport(target, poolKey, groupId, segmentProxies, options.signal, normalized, options.onTrafficBytes);
|
||||
if (probe.status === "unsupported") {
|
||||
return { status: "fallback", reason: "range_unsupported" };
|
||||
}
|
||||
@@ -794,25 +923,26 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
await initialHandle.close();
|
||||
}
|
||||
|
||||
const segments = buildSegments(probe.totalBytes, connections);
|
||||
const segments = buildSegments(probe.totalBytes, connections, minSegmentBytes);
|
||||
const segmentController = new AbortController();
|
||||
const cascadeAbort = (): void => segmentController.abort("parent_abort");
|
||||
options.signal.addEventListener("abort", cascadeAbort, { once: true });
|
||||
const signal = AbortSignal.any([options.signal, segmentController.signal]);
|
||||
|
||||
const tasks = segments.map(async (segment) => {
|
||||
const downloadSegment = async (segment: Segment): Promise<number> => {
|
||||
const excluded = new Set<string>();
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < normalized.proxyAttempts; attempt += 1) {
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
const lease = await sharedProxyCoordinator.acquire(poolKey, segmentProxies, excluded, signal);
|
||||
const lease = await sharedProxyCoordinator.acquire(poolKey, groupId, segmentProxies, excluded, signal);
|
||||
if (!lease) {
|
||||
break;
|
||||
}
|
||||
excluded.add(proxyEndpointKey(lease.proxy));
|
||||
let attemptProgress = 0;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const bytes = await downloadSegmentOnce(
|
||||
target,
|
||||
@@ -832,10 +962,18 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
probe.totalBytes,
|
||||
() => committedProgress
|
||||
);
|
||||
lease.release(true);
|
||||
lease.release({
|
||||
succeeded: true,
|
||||
transferredBytes: bytes,
|
||||
durationMs: Math.max(1, Date.now() - startedAt)
|
||||
});
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
lease.release(error instanceof OriginHttpError);
|
||||
const originError = error instanceof OriginHttpError;
|
||||
lease.release({ succeeded: originError || signal.aborted });
|
||||
if (originError) {
|
||||
sharedProxyCoordinator.reportOriginStatus(error.statusCode);
|
||||
}
|
||||
if (attemptProgress > 0) {
|
||||
updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes);
|
||||
}
|
||||
@@ -846,6 +984,19 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("proxy_segment_failed");
|
||||
};
|
||||
|
||||
let nextSegmentIndex = 0;
|
||||
const tasks = Array.from({ length: Math.min(connections, segments.length) }, async () => {
|
||||
let downloadedBytes = 0;
|
||||
while (!signal.aborted) {
|
||||
const segmentIndex = nextSegmentIndex;
|
||||
nextSegmentIndex += 1;
|
||||
const segment = segments[segmentIndex];
|
||||
if (!segment) break;
|
||||
downloadedBytes += await downloadSegment(segment);
|
||||
}
|
||||
return downloadedBytes;
|
||||
});
|
||||
|
||||
const guardedTasks = tasks.map((task) => task.catch((error) => {
|
||||
@@ -907,4 +1058,7 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
updateProgress(-committedProgress, 0, probe.totalBytes);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
unregisterGroup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +953,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||
proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 16,
|
||||
proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 20,
|
||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||
theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
|
||||
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||
@@ -6067,7 +6067,7 @@ export function App(): ReactElement {
|
||||
maxParallelExtract: [1, 8, 2],
|
||||
reconnectWaitSeconds: [10, 600, 45],
|
||||
proxyApiProxyIndex: [1, 100000, 1],
|
||||
proxyConnectionsPerDownload: [2, 32, 16]
|
||||
proxyConnectionsPerDownload: [2, 32, 20]
|
||||
};
|
||||
const bounds = numericLimits[fieldId as keyof RendererSettingsDraft];
|
||||
if (bounds) {
|
||||
|
||||
@@ -362,7 +362,11 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
|
||||
const allDownloaded = done + failed + cancelled >= total;
|
||||
const allExtracted = extracted >= total;
|
||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
||||
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
|
||||
const sizeProgress = getPackageSizeProgress(row);
|
||||
const downloadRatio = sizeProgress.total > 0
|
||||
? Math.max(0, Math.min(1, sizeProgress.downloaded / sizeProgress.total))
|
||||
: Math.max(0, Math.min(1, (done + activeProgress) / total));
|
||||
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(downloadRatio * (useExtractSplit ? 50 : 100)));
|
||||
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
||||
return { done, failed, cancelled, total, value };
|
||||
|
||||
@@ -462,7 +462,7 @@ export function buildSettingsFormViewModel({
|
||||
min: 2,
|
||||
max: 32,
|
||||
disabled: !settings.proxyDownloadEnabled,
|
||||
help: `Dieses Gesamtlimit wird zwischen allen gleichzeitigen Segmentdownloads geteilt. Bei ${settings.maxParallel} aktiven Downloads laufen zusammen höchstens ${settings.proxyConnectionsPerDownload} Segmentverbindungen. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.`
|
||||
help: `Dieses Gesamtlimit wird fair zwischen allen gleichzeitigen Segmentdownloads geteilt. Bei ${settings.maxParallel} aktiven Downloads laufen zusammen höchstens ${settings.proxyConnectionsPerDownload} Segmentverbindungen; 20 ist der empfohlene Startwert. Langsame und ausgefallene Proxys werden automatisch zurückgestuft. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.`
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -239,6 +239,29 @@ describe("Download-Gesamtgröße", () => {
|
||||
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_500, total: 3_000, value: 83 });
|
||||
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 2, total: 3, value: 83 }));
|
||||
});
|
||||
|
||||
it("uses downloaded bytes instead of equally weighting differently sized files", () => {
|
||||
const completed = item("completed", "package-a", "completed", {
|
||||
downloadedBytes: 2_000,
|
||||
totalBytes: 2_000,
|
||||
progressPercent: 100
|
||||
});
|
||||
const active = item("active", "package-a", "downloading", {
|
||||
downloadedBytes: 250,
|
||||
totalBytes: 1_000,
|
||||
progressPercent: 25
|
||||
});
|
||||
const queued = item("queued", "package-a", "queued", {
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 15_600,
|
||||
progressPercent: 0
|
||||
});
|
||||
const packageEntry = pkg("package-a", "Serie", ["completed", "active", "queued"]);
|
||||
const row = { package: packageEntry, items: [completed, active, queued], allItems: [completed, active, queued], collapsed: true };
|
||||
|
||||
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_250, total: 18_600, value: 12 });
|
||||
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 1, total: 3, value: 12 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Download-ETA", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { downloadWithProxySegments, parseProxyList, selectFixedProxy } from "../src/main/proxy-segmented-download";
|
||||
|
||||
@@ -71,7 +72,8 @@ async function createRangeServer(content: Buffer, supportsRanges = true): Promis
|
||||
async function createConnectProxy(
|
||||
username: string,
|
||||
password: string,
|
||||
acceptedConnections: { value: number; active?: number; maxActive?: number }
|
||||
acceptedConnections: { value: number; active?: number; maxActive?: number },
|
||||
responseDelayMs = 0
|
||||
): Promise<RunningServer> {
|
||||
const server = http.createServer();
|
||||
server.on("connect", (request, clientSocket, head) => {
|
||||
@@ -96,7 +98,16 @@ async function createConnectProxy(
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length > 0) upstream.write(head);
|
||||
clientSocket.pipe(upstream);
|
||||
if (responseDelayMs > 0) {
|
||||
const delayed = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
setTimeout(() => callback(null, chunk), responseDelayMs);
|
||||
}
|
||||
});
|
||||
upstream.pipe(delayed).pipe(clientSocket);
|
||||
} else {
|
||||
upstream.pipe(clientSocket);
|
||||
}
|
||||
});
|
||||
upstream.once("error", () => clientSocket.destroy());
|
||||
clientSocket.once("error", () => upstream.destroy());
|
||||
@@ -184,7 +195,7 @@ describe("proxy segmented download", () => {
|
||||
expect(await fs.promises.readFile(targetFile)).toEqual(content);
|
||||
expect(proxyCounters.every((counter) => counter.value > 0)).toBe(true);
|
||||
expect(targetServer.ranges).toContain("bytes=0-1");
|
||||
expect(new Set(targetServer.ranges.filter((range) => range !== "bytes=0-1")).size).toBe(4);
|
||||
expect(new Set(targetServer.ranges.filter((range) => range !== "bytes=0-1")).size).toBe(32);
|
||||
expect(progress.at(-1)).toBe(content.length);
|
||||
expect(trafficBytes).toBe(content.length + 2);
|
||||
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
|
||||
@@ -218,11 +229,46 @@ describe("proxy segmented download", () => {
|
||||
expect(counters.some((counter) => counter.value > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("lets fast proxies take over additional rolling chunks", async () => {
|
||||
const content = Buffer.allocUnsafe(512 * 1024);
|
||||
for (let index = 0; index < content.length; index += 1) content[index] = index % 233;
|
||||
const targetServer = await createRangeServer(content);
|
||||
const fastCounter = { value: 0 };
|
||||
const slowCounter = { value: 0 };
|
||||
const fastProxy = await createConnectProxy("fast", "secret", fastCounter);
|
||||
const slowProxy = await createConnectProxy("slow", "secret", slowCounter, 40);
|
||||
const directory = await createTempDirectory();
|
||||
const proxyFile = path.join(directory, "speed-proxies.txt");
|
||||
const targetFile = path.join(directory, "rolling.bin");
|
||||
await fs.promises.writeFile(proxyFile, [
|
||||
`fast:secret@127.0.0.1:${fastProxy.port}`,
|
||||
`slow:secret@127.0.0.1:${slowProxy.port}`
|
||||
].join("\n"));
|
||||
|
||||
const result = await downloadWithProxySegments({
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/rolling.bin`,
|
||||
targetPath: targetFile,
|
||||
proxyListPath: proxyFile,
|
||||
connections: 2,
|
||||
totalConnectionLimit: 2,
|
||||
signal: new AbortController().signal,
|
||||
minSegmentBytes: 1
|
||||
});
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(await fs.promises.readFile(targetFile)).toEqual(content);
|
||||
expect(fastCounter.value).toBeGreaterThan(slowCounter.value * 2);
|
||||
expect(new Set(targetServer.ranges.filter((range) => range !== "bytes=0-1")).size).toBe(16);
|
||||
});
|
||||
|
||||
it("shares one connection budget across concurrent downloads and reserves the fixed API proxy", async () => {
|
||||
const content = Buffer.allocUnsafe(128 * 1024);
|
||||
for (let index = 0; index < content.length; index += 1) content[index] = index % 239;
|
||||
let activeRequests = 0;
|
||||
let maxActiveRequests = 0;
|
||||
const activeByDownload = new Map<string, number>();
|
||||
const maxActiveByDownload = new Map<string, number>();
|
||||
let downloadsOverlapped = false;
|
||||
const server = http.createServer((request, response) => {
|
||||
const match = /^bytes=(\d+)-(\d+)$/.exec(String(request.headers.range || ""));
|
||||
if (!match) {
|
||||
@@ -233,13 +279,18 @@ describe("proxy segmented download", () => {
|
||||
const start = Number(match[1]);
|
||||
const end = Math.min(Number(match[2]), content.length - 1);
|
||||
const body = content.subarray(start, end + 1);
|
||||
const downloadKey = new URL(request.url || "/", "http://localhost").searchParams.get("download") || "unknown";
|
||||
activeRequests += 1;
|
||||
activeByDownload.set(downloadKey, (activeByDownload.get(downloadKey) || 0) + 1);
|
||||
maxActiveByDownload.set(downloadKey, Math.max(maxActiveByDownload.get(downloadKey) || 0, activeByDownload.get(downloadKey) || 0));
|
||||
downloadsOverlapped ||= (activeByDownload.get("first") || 0) > 0 && (activeByDownload.get("second") || 0) > 0;
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||
let finished = false;
|
||||
const finish = (): void => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
activeRequests = Math.max(0, activeRequests - 1);
|
||||
activeByDownload.set(downloadKey, Math.max(0, (activeByDownload.get(downloadKey) || 1) - 1));
|
||||
};
|
||||
response.once("close", finish);
|
||||
response.writeHead(206, {
|
||||
@@ -264,7 +315,6 @@ describe("proxy segmented download", () => {
|
||||
proxies.map((proxy, index) => `shared${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||
);
|
||||
const commonOptions = {
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin`,
|
||||
proxyListPath: proxyFile,
|
||||
connections: 4,
|
||||
totalConnectionLimit: 4,
|
||||
@@ -274,8 +324,8 @@ describe("proxy segmented download", () => {
|
||||
};
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
downloadWithProxySegments({ ...commonOptions, targetPath: firstTarget }),
|
||||
downloadWithProxySegments({ ...commonOptions, targetPath: secondTarget })
|
||||
downloadWithProxySegments({ ...commonOptions, directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin?download=first`, targetPath: firstTarget }),
|
||||
downloadWithProxySegments({ ...commonOptions, directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin?download=second`, targetPath: secondTarget })
|
||||
]);
|
||||
|
||||
expect(firstResult.status).toBe("completed");
|
||||
@@ -283,10 +333,91 @@ describe("proxy segmented download", () => {
|
||||
expect(await fs.promises.readFile(firstTarget)).toEqual(content);
|
||||
expect(await fs.promises.readFile(secondTarget)).toEqual(content);
|
||||
expect(maxActiveRequests).toBeLessThanOrEqual(4);
|
||||
expect(maxActiveByDownload.get("first")).toBeLessThanOrEqual(2);
|
||||
expect(maxActiveByDownload.get("second")).toBeLessThanOrEqual(2);
|
||||
expect(downloadsOverlapped).toBe(true);
|
||||
expect(counters[0].value).toBe(0);
|
||||
expect(counters.every((counter) => counter.maxActive <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("reduces new parallel range requests after a transient origin 503", async () => {
|
||||
const content = Buffer.allocUnsafe(192 * 1024);
|
||||
for (let index = 0; index < content.length; index += 1) content[index] = index % 227;
|
||||
let activeRequests = 0;
|
||||
let initialRequests = 0;
|
||||
let maxActiveAfter503 = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
const rangeHeader = String(request.headers.range || "");
|
||||
const match = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader);
|
||||
if (!match) {
|
||||
response.writeHead(416);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const end = Math.min(Number(match[2]), content.length - 1);
|
||||
const body = content.subarray(start, end + 1);
|
||||
if (rangeHeader === "bytes=0-1") {
|
||||
response.writeHead(206, {
|
||||
"Content-Range": `bytes 0-1/${content.length}`,
|
||||
"Content-Length": 2
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
initialRequests += 1;
|
||||
activeRequests += 1;
|
||||
if (initialRequests > 6) {
|
||||
maxActiveAfter503 = Math.max(maxActiveAfter503, activeRequests);
|
||||
}
|
||||
let finished = false;
|
||||
const finish = (): void => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
activeRequests = Math.max(0, activeRequests - 1);
|
||||
};
|
||||
response.once("close", finish);
|
||||
if (initialRequests === 6) {
|
||||
response.writeHead(503, { "Content-Length": 0 });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const delayMs = initialRequests < 6 ? 80 : 5;
|
||||
setTimeout(() => {
|
||||
response.writeHead(206, {
|
||||
"Content-Range": `bytes ${start}-${end}/${content.length}`,
|
||||
"Content-Length": body.length
|
||||
});
|
||||
response.end(body);
|
||||
}, delayMs);
|
||||
});
|
||||
const targetServer = await listen(server);
|
||||
const counters = Array.from({ length: 6 }, () => ({ value: 0 }));
|
||||
const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`adaptive${index}`, `pass${index}`, counter)));
|
||||
const directory = await createTempDirectory();
|
||||
const proxyFile = path.join(directory, "adaptive-proxies.txt");
|
||||
const targetFile = path.join(directory, "adaptive.bin");
|
||||
await fs.promises.writeFile(
|
||||
proxyFile,
|
||||
proxies.map((proxy, index) => `adaptive${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||
);
|
||||
|
||||
const result = await downloadWithProxySegments({
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/adaptive.bin`,
|
||||
targetPath: targetFile,
|
||||
proxyListPath: proxyFile,
|
||||
connections: 6,
|
||||
totalConnectionLimit: 6,
|
||||
signal: new AbortController().signal,
|
||||
minSegmentBytes: 1
|
||||
});
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(await fs.promises.readFile(targetFile)).toEqual(content);
|
||||
expect(initialRequests).toBeGreaterThan(6);
|
||||
expect(maxActiveAfter503).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("reports an origin HTTP status instead of marking reachable proxies unavailable", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(503, { "Content-Type": "text/plain", "Content-Length": 19 });
|
||||
|
||||
@@ -613,6 +613,10 @@ describe("settings storage", () => {
|
||||
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
|
||||
});
|
||||
|
||||
it("uses 20 total proxy connections as the default for new settings", () => {
|
||||
expect(defaultSettings().proxyConnectionsPerDownload).toBe(20);
|
||||
});
|
||||
|
||||
it("migrates the previous private update repository to the public release repository", () => {
|
||||
for (const updateRepo of [
|
||||
"legacy-owner/real-debrid-downloader",
|
||||
|
||||
@@ -120,7 +120,7 @@ function createSettings(): AppSettings {
|
||||
proxyDownloadEnabled: false,
|
||||
proxyListPath: "",
|
||||
proxyApiProxyIndex: 1,
|
||||
proxyConnectionsPerDownload: 16,
|
||||
proxyConnectionsPerDownload: 20,
|
||||
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: true,
|
||||
|
||||
Reference in New Issue
Block a user