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]
|
## [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
|
## [2.0.76] - 2026-08-31
|
||||||
|
|
||||||
### Proxy concurrency hotfix
|
### 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
|
- Verifiziert am: 31. August 2026, Europe/Berlin
|
||||||
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
||||||
- Arbeitsbranch: `release/v2.0.76`
|
- Arbeitsbranch: `optimize/proxy-scheduler-v2.0.77`
|
||||||
- Quellbasis: `release/v2.0.75`
|
- Quellbasis: `release/v2.0.76`
|
||||||
- Release-Tag: `v2.0.76`
|
- Release-Tag: `v2.0.76`
|
||||||
- Baseline-Commit: `741194e48f7dc4d1af4aed12430dac882f91273f`
|
- Baseline-Commit: `ecfc630e1e68c4067f3a019e68d38abb07d92a14`
|
||||||
- Paketversion: `2.0.76`
|
- Paketversion: `2.0.76`
|
||||||
- Letztes Release: `Multi-Debrid-Downloader v2.0.76`, veröffentlicht am 31. August 2026 auf GitHub und Forgejo
|
- 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`
|
- 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.
|
- 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.
|
- 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`.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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“.
|
- 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.
|
- 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
|
## Start-, Build- und Testbefehle
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -159,6 +169,11 @@ npm exec -- tsc --noEmit
|
|||||||
|
|
||||||
## Verifizierungen vom 31. August 2026
|
## 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-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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository.
|
||||||
- Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle.
|
- Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle.
|
||||||
@@ -216,7 +231,7 @@ npm exec -- tsc --noEmit
|
|||||||
|
|
||||||
## Nächste sinnvolle Schritte
|
## 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.
|
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.
|
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.
|
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,
|
proxyDownloadEnabled: false,
|
||||||
proxyListPath: "",
|
proxyListPath: "",
|
||||||
proxyApiProxyIndex: 1,
|
proxyApiProxyIndex: 1,
|
||||||
proxyConnectionsPerDownload: 16,
|
proxyConnectionsPerDownload: 20,
|
||||||
updateRepo: DEFAULT_UPDATE_REPO,
|
updateRepo: DEFAULT_UPDATE_REPO,
|
||||||
autoUpdateCheck: true,
|
autoUpdateCheck: true,
|
||||||
clipboardWatch: false,
|
clipboardWatch: false,
|
||||||
|
|||||||
@@ -11032,6 +11032,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
proxyListPath: this.settings.proxyListPath,
|
proxyListPath: this.settings.proxyListPath,
|
||||||
connections: this.settings.proxyConnectionsPerDownload,
|
connections: this.settings.proxyConnectionsPerDownload,
|
||||||
totalConnectionLimit: this.settings.proxyConnectionsPerDownload,
|
totalConnectionLimit: this.settings.proxyConnectionsPerDownload,
|
||||||
|
downloadId: active.itemId,
|
||||||
reservedProxyIndex: this.settings.proxyApiProxyIndex,
|
reservedProxyIndex: this.settings.proxyApiProxyIndex,
|
||||||
signal: active.abortController.signal,
|
signal: active.abortController.signal,
|
||||||
skipTlsVerify,
|
skipTlsVerify,
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { HttpsProxyAgent } from "https-proxy-agent";
|
|||||||
|
|
||||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||||
const DEFAULT_IDLE_TIMEOUT_MS = 45_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 DEFAULT_PROXY_ATTEMPTS = 3;
|
||||||
const PROXY_PROBE_END = 1;
|
const PROXY_PROBE_END = 1;
|
||||||
const MAX_REDIRECTS = 5;
|
const MAX_REDIRECTS = 5;
|
||||||
|
const MAX_SEGMENTS = 4_096;
|
||||||
const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024;
|
const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024;
|
||||||
const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]);
|
const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]);
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ export interface ProxySegmentedDownloadOptions {
|
|||||||
proxyListPath: string;
|
proxyListPath: string;
|
||||||
connections: number;
|
connections: number;
|
||||||
totalConnectionLimit?: number;
|
totalConnectionLimit?: number;
|
||||||
|
downloadId?: string;
|
||||||
reservedProxyIndex?: number;
|
reservedProxyIndex?: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
skipTlsVerify?: boolean;
|
skipTlsVerify?: boolean;
|
||||||
@@ -92,13 +96,20 @@ class OriginHttpError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProxyLeaseOutcome {
|
||||||
|
succeeded: boolean;
|
||||||
|
transferredBytes?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProxyLease {
|
interface ProxyLease {
|
||||||
proxy: ProxyEndpoint;
|
proxy: ProxyEndpoint;
|
||||||
release: (succeeded: boolean) => void;
|
release: (outcome: ProxyLeaseOutcome) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyLeaseWaiter {
|
interface ProxyLeaseWaiter {
|
||||||
poolKey: string;
|
poolKey: string;
|
||||||
|
groupId: string;
|
||||||
proxies: ProxyEndpoint[];
|
proxies: ProxyEndpoint[];
|
||||||
excluded: ReadonlySet<string>;
|
excluded: ReadonlySet<string>;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
@@ -106,21 +117,70 @@ interface ProxyLeaseWaiter {
|
|||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProxyPerformance {
|
||||||
|
ewmaBytesPerSecond: number;
|
||||||
|
failures: number;
|
||||||
|
samples: number;
|
||||||
|
}
|
||||||
|
|
||||||
class SharedProxyCoordinator {
|
class SharedProxyCoordinator {
|
||||||
private connectionLimit = 16;
|
private configuredConnectionLimit = 16;
|
||||||
|
private adaptiveConnectionLimit = 16;
|
||||||
private activeConnections = 0;
|
private activeConnections = 0;
|
||||||
private readonly activeProxies = new Set<string>();
|
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 cooldownUntil = new Map<string, number>();
|
||||||
private readonly cursors = new Map<string, number>();
|
private readonly cursors = new Map<string, number>();
|
||||||
|
private readonly performance = new Map<string, ProxyPerformance>();
|
||||||
private readonly waiters: ProxyLeaseWaiter[] = [];
|
private readonly waiters: ProxyLeaseWaiter[] = [];
|
||||||
|
private acquisitionCount = 0;
|
||||||
|
private lastThrottleAt = 0;
|
||||||
|
private successfulTransfersSinceThrottle = 0;
|
||||||
|
|
||||||
public setConnectionLimit(limit: number): void {
|
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();
|
this.drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
public acquire(
|
public acquire(
|
||||||
poolKey: string,
|
poolKey: string,
|
||||||
|
groupId: string,
|
||||||
proxies: ProxyEndpoint[],
|
proxies: ProxyEndpoint[],
|
||||||
excluded: ReadonlySet<string>,
|
excluded: ReadonlySet<string>,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
@@ -132,7 +192,7 @@ class SharedProxyCoordinator {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
return new Promise<ProxyLease | null>((resolve, reject) => {
|
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 onAbort = (): void => {
|
||||||
const index = this.waiters.indexOf(waiter);
|
const index = this.waiters.indexOf(waiter);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -157,12 +217,18 @@ class SharedProxyCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private selectProxy(waiter: ProxyLeaseWaiter): ProxyEndpoint | null {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = (this.cursors.get(waiter.poolKey) || 0) % waiter.proxies.length;
|
const start = (this.cursors.get(waiter.poolKey) || 0) % waiter.proxies.length;
|
||||||
const select = (allowCooldown: boolean): ProxyEndpoint | null => {
|
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) {
|
for (let offset = 0; offset < waiter.proxies.length; offset += 1) {
|
||||||
const index = (start + offset) % waiter.proxies.length;
|
const index = (start + offset) % waiter.proxies.length;
|
||||||
const proxy = waiter.proxies[index];
|
const proxy = waiter.proxies[index];
|
||||||
@@ -172,39 +238,93 @@ class SharedProxyCoordinator {
|
|||||||
|| (!allowCooldown && (this.cooldownUntil.get(key) || 0) > now)) {
|
|| (!allowCooldown && (this.cooldownUntil.get(key) || 0) > now)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.cursors.set(waiter.poolKey, (index + 1) % waiter.proxies.length);
|
candidates.push({ proxy, index, key });
|
||||||
return proxy;
|
|
||||||
}
|
}
|
||||||
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);
|
const key = proxyEndpointKey(proxy);
|
||||||
this.activeConnections += 1;
|
this.activeConnections += 1;
|
||||||
this.activeProxies.add(key);
|
this.activeProxies.add(key);
|
||||||
|
this.activeConnectionsByGroup.set(groupId, (this.activeConnectionsByGroup.get(groupId) || 0) + 1);
|
||||||
let released = false;
|
let released = false;
|
||||||
return {
|
return {
|
||||||
proxy,
|
proxy,
|
||||||
release: (succeeded) => {
|
release: (outcome) => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
||||||
this.activeProxies.delete(key);
|
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);
|
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 {
|
} else {
|
||||||
this.cooldownUntil.set(key, Date.now() + 30_000);
|
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();
|
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 {
|
private drain(): void {
|
||||||
let granted = true;
|
let granted = true;
|
||||||
while (granted && this.activeConnections < this.connectionLimit) {
|
while (granted && this.activeConnections < this.adaptiveConnectionLimit) {
|
||||||
granted = false;
|
granted = false;
|
||||||
for (let index = 0; index < this.waiters.length; index += 1) {
|
for (let index = 0; index < this.waiters.length; index += 1) {
|
||||||
const waiter = this.waiters[index];
|
const waiter = this.waiters[index];
|
||||||
@@ -219,7 +339,7 @@ class SharedProxyCoordinator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.waiters.splice(index, 1);
|
this.waiters.splice(index, 1);
|
||||||
waiter.resolve(this.createLease(proxy));
|
waiter.resolve(this.createLease(proxy, waiter.groupId));
|
||||||
granted = true;
|
granted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -512,6 +632,7 @@ async function consumeProbe(response: IncomingMessage, expectedBytes: number, on
|
|||||||
async function probeRangeSupport(
|
async function probeRangeSupport(
|
||||||
target: URL,
|
target: URL,
|
||||||
poolKey: string,
|
poolKey: string,
|
||||||
|
groupId: string,
|
||||||
proxies: ProxyEndpoint[],
|
proxies: ProxyEndpoint[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs" | "proxyAttempts">>,
|
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs" | "proxyAttempts">>,
|
||||||
@@ -529,7 +650,7 @@ async function probeRangeSupport(
|
|||||||
let opened: RangeResponse | null = null;
|
let opened: RangeResponse | null = null;
|
||||||
let lease: ProxyLease | null = null;
|
let lease: ProxyLease | null = null;
|
||||||
try {
|
try {
|
||||||
lease = await sharedProxyCoordinator.acquire(poolKey, proxies, excluded, signal);
|
lease = await sharedProxyCoordinator.acquire(poolKey, groupId, proxies, excluded, signal);
|
||||||
if (!lease) break;
|
if (!lease) break;
|
||||||
excluded.add(proxyEndpointKey(lease.proxy));
|
excluded.add(proxyEndpointKey(lease.proxy));
|
||||||
opened = await openRangeResponse(
|
opened = await openRangeResponse(
|
||||||
@@ -547,14 +668,14 @@ async function probeRangeSupport(
|
|||||||
lastHttpStatus = status;
|
lastHttpStatus = status;
|
||||||
opened.dispose();
|
opened.dispose();
|
||||||
opened = null;
|
opened = null;
|
||||||
lease.release(true);
|
lease.release({ succeeded: true });
|
||||||
lease = null;
|
lease = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
opened.dispose();
|
opened.dispose();
|
||||||
opened = null;
|
opened = null;
|
||||||
lease.release(true);
|
lease.release({ succeeded: true });
|
||||||
lease = null;
|
lease = null;
|
||||||
return { status: "unsupported" };
|
return { status: "unsupported" };
|
||||||
}
|
}
|
||||||
@@ -569,7 +690,7 @@ async function probeRangeSupport(
|
|||||||
|| (contentLength > 0 && contentLength !== expectedBytes)) {
|
|| (contentLength > 0 && contentLength !== expectedBytes)) {
|
||||||
opened.dispose();
|
opened.dispose();
|
||||||
opened = null;
|
opened = null;
|
||||||
lease.release(false);
|
lease.release({ succeeded: false });
|
||||||
lease = null;
|
lease = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -577,14 +698,14 @@ async function probeRangeSupport(
|
|||||||
opened.dispose();
|
opened.dispose();
|
||||||
opened = null;
|
opened = null;
|
||||||
if (received === expectedBytes) {
|
if (received === expectedBytes) {
|
||||||
lease.release(true);
|
lease.release({ succeeded: true });
|
||||||
lease = null;
|
lease = null;
|
||||||
return { status: "ok", totalBytes: range.total };
|
return { status: "ok", totalBytes: range.total };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
opened?.dispose();
|
opened?.dispose();
|
||||||
opened = null;
|
opened = null;
|
||||||
lease?.release(false);
|
lease?.release({ succeeded: signal.aborted });
|
||||||
lease = null;
|
lease = null;
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
@@ -594,23 +715,28 @@ async function probeRangeSupport(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
opened?.dispose();
|
opened?.dispose();
|
||||||
lease?.release(false);
|
lease?.release({ succeeded: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastHttpStatus > 0) {
|
if (lastHttpStatus > 0) {
|
||||||
|
sharedProxyCoordinator.reportOriginStatus(lastHttpStatus);
|
||||||
return { status: "origin_error", httpStatus: lastHttpStatus };
|
return { status: "origin_error", httpStatus: lastHttpStatus };
|
||||||
}
|
}
|
||||||
return { status: "unavailable" };
|
return { status: "unavailable" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSegments(totalBytes: number, count: number): Segment[] {
|
function buildSegments(totalBytes: number, connections: number, minSegmentBytes: number): Segment[] {
|
||||||
const baseSize = Math.floor(totalBytes / count);
|
const preferredChunkBytes = Math.max(
|
||||||
const remainder = totalBytes % count;
|
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;
|
let start = 0;
|
||||||
return Array.from({ length: count }, (_, index) => {
|
return Array.from({ length: count }, (_, index) => {
|
||||||
const size = baseSize + (index < remainder ? 1 : 0);
|
const end = Math.min(totalBytes - 1, start + chunkBytes - 1);
|
||||||
const segment = { index, start, end: start + size - 1 };
|
const segment = { index, start, end };
|
||||||
start += size;
|
start = end + 1;
|
||||||
return segment;
|
return segment;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -736,13 +862,16 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
|||||||
return { status: "fallback", reason: "proxy_unavailable" };
|
return { status: "fallback", reason: "proxy_unavailable" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupId = String(options.downloadId || options.targetPath || randomUUID());
|
||||||
|
const unregisterGroup = sharedProxyCoordinator.registerGroup(groupId);
|
||||||
|
try {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
skipTlsVerify: Boolean(options.skipTlsVerify),
|
skipTlsVerify: Boolean(options.skipTlsVerify),
|
||||||
connectTimeoutMs: Math.max(1_000, Math.floor(options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)),
|
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)),
|
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)))
|
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") {
|
if (probe.status === "unsupported") {
|
||||||
return { status: "fallback", reason: "range_unsupported" };
|
return { status: "fallback", reason: "range_unsupported" };
|
||||||
}
|
}
|
||||||
@@ -794,25 +923,26 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
|||||||
await initialHandle.close();
|
await initialHandle.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = buildSegments(probe.totalBytes, connections);
|
const segments = buildSegments(probe.totalBytes, connections, minSegmentBytes);
|
||||||
const segmentController = new AbortController();
|
const segmentController = new AbortController();
|
||||||
const cascadeAbort = (): void => segmentController.abort("parent_abort");
|
const cascadeAbort = (): void => segmentController.abort("parent_abort");
|
||||||
options.signal.addEventListener("abort", cascadeAbort, { once: true });
|
options.signal.addEventListener("abort", cascadeAbort, { once: true });
|
||||||
const signal = AbortSignal.any([options.signal, segmentController.signal]);
|
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>();
|
const excluded = new Set<string>();
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 0; attempt < normalized.proxyAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < normalized.proxyAttempts; attempt += 1) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
const lease = await sharedProxyCoordinator.acquire(poolKey, segmentProxies, excluded, signal);
|
const lease = await sharedProxyCoordinator.acquire(poolKey, groupId, segmentProxies, excluded, signal);
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
excluded.add(proxyEndpointKey(lease.proxy));
|
excluded.add(proxyEndpointKey(lease.proxy));
|
||||||
let attemptProgress = 0;
|
let attemptProgress = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const bytes = await downloadSegmentOnce(
|
const bytes = await downloadSegmentOnce(
|
||||||
target,
|
target,
|
||||||
@@ -832,10 +962,18 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
|||||||
probe.totalBytes,
|
probe.totalBytes,
|
||||||
() => committedProgress
|
() => committedProgress
|
||||||
);
|
);
|
||||||
lease.release(true);
|
lease.release({
|
||||||
|
succeeded: true,
|
||||||
|
transferredBytes: bytes,
|
||||||
|
durationMs: Math.max(1, Date.now() - startedAt)
|
||||||
|
});
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (error) {
|
} 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) {
|
if (attemptProgress > 0) {
|
||||||
updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes);
|
updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes);
|
||||||
}
|
}
|
||||||
@@ -846,6 +984,19 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError || new Error("proxy_segment_failed");
|
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) => {
|
const guardedTasks = tasks.map((task) => task.catch((error) => {
|
||||||
@@ -907,4 +1058,7 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
|||||||
updateProgress(-committedProgress, 0, probe.totalBytes);
|
updateProgress(-committedProgress, 0, probe.totalBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
unregisterGroup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -953,7 +953,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
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,
|
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,
|
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,
|
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||||
@@ -6067,7 +6067,7 @@ export function App(): ReactElement {
|
|||||||
maxParallelExtract: [1, 8, 2],
|
maxParallelExtract: [1, 8, 2],
|
||||||
reconnectWaitSeconds: [10, 600, 45],
|
reconnectWaitSeconds: [10, 600, 45],
|
||||||
proxyApiProxyIndex: [1, 100000, 1],
|
proxyApiProxyIndex: [1, 100000, 1],
|
||||||
proxyConnectionsPerDownload: [2, 32, 16]
|
proxyConnectionsPerDownload: [2, 32, 20]
|
||||||
};
|
};
|
||||||
const bounds = numericLimits[fieldId as keyof RendererSettingsDraft];
|
const bounds = numericLimits[fieldId as keyof RendererSettingsDraft];
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
|
|||||||
@@ -362,7 +362,11 @@ export function getPackageProgress(row: DownloadPackageRow): { done: number; fai
|
|||||||
const allDownloaded = done + failed + cancelled >= total;
|
const allDownloaded = done + failed + cancelled >= total;
|
||||||
const allExtracted = extracted >= total;
|
const allExtracted = extracted >= total;
|
||||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
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 extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||||
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
||||||
return { done, failed, cancelled, total, value };
|
return { done, failed, cancelled, total, value };
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export function buildSettingsFormViewModel({
|
|||||||
min: 2,
|
min: 2,
|
||||||
max: 32,
|
max: 32,
|
||||||
disabled: !settings.proxyDownloadEnabled,
|
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(getPackageSizeProgress(row)).toEqual({ downloaded: 2_500, total: 3_000, value: 83 });
|
||||||
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 2, total: 3, 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", () => {
|
describe("Download-ETA", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import http from "node:http";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Transform } from "node:stream";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { downloadWithProxySegments, parseProxyList, selectFixedProxy } from "../src/main/proxy-segmented-download";
|
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(
|
async function createConnectProxy(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
acceptedConnections: { value: number; active?: number; maxActive?: number }
|
acceptedConnections: { value: number; active?: number; maxActive?: number },
|
||||||
|
responseDelayMs = 0
|
||||||
): Promise<RunningServer> {
|
): Promise<RunningServer> {
|
||||||
const server = http.createServer();
|
const server = http.createServer();
|
||||||
server.on("connect", (request, clientSocket, head) => {
|
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");
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
if (head.length > 0) upstream.write(head);
|
if (head.length > 0) upstream.write(head);
|
||||||
clientSocket.pipe(upstream);
|
clientSocket.pipe(upstream);
|
||||||
upstream.pipe(clientSocket);
|
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());
|
upstream.once("error", () => clientSocket.destroy());
|
||||||
clientSocket.once("error", () => upstream.destroy());
|
clientSocket.once("error", () => upstream.destroy());
|
||||||
@@ -184,7 +195,7 @@ describe("proxy segmented download", () => {
|
|||||||
expect(await fs.promises.readFile(targetFile)).toEqual(content);
|
expect(await fs.promises.readFile(targetFile)).toEqual(content);
|
||||||
expect(proxyCounters.every((counter) => counter.value > 0)).toBe(true);
|
expect(proxyCounters.every((counter) => counter.value > 0)).toBe(true);
|
||||||
expect(targetServer.ranges).toContain("bytes=0-1");
|
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(progress.at(-1)).toBe(content.length);
|
||||||
expect(trafficBytes).toBe(content.length + 2);
|
expect(trafficBytes).toBe(content.length + 2);
|
||||||
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
|
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);
|
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 () => {
|
it("shares one connection budget across concurrent downloads and reserves the fixed API proxy", async () => {
|
||||||
const content = Buffer.allocUnsafe(128 * 1024);
|
const content = Buffer.allocUnsafe(128 * 1024);
|
||||||
for (let index = 0; index < content.length; index += 1) content[index] = index % 239;
|
for (let index = 0; index < content.length; index += 1) content[index] = index % 239;
|
||||||
let activeRequests = 0;
|
let activeRequests = 0;
|
||||||
let maxActiveRequests = 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 server = http.createServer((request, response) => {
|
||||||
const match = /^bytes=(\d+)-(\d+)$/.exec(String(request.headers.range || ""));
|
const match = /^bytes=(\d+)-(\d+)$/.exec(String(request.headers.range || ""));
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -233,13 +279,18 @@ describe("proxy segmented download", () => {
|
|||||||
const start = Number(match[1]);
|
const start = Number(match[1]);
|
||||||
const end = Math.min(Number(match[2]), content.length - 1);
|
const end = Math.min(Number(match[2]), content.length - 1);
|
||||||
const body = content.subarray(start, end + 1);
|
const body = content.subarray(start, end + 1);
|
||||||
|
const downloadKey = new URL(request.url || "/", "http://localhost").searchParams.get("download") || "unknown";
|
||||||
activeRequests += 1;
|
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);
|
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||||
let finished = false;
|
let finished = false;
|
||||||
const finish = (): void => {
|
const finish = (): void => {
|
||||||
if (finished) return;
|
if (finished) return;
|
||||||
finished = true;
|
finished = true;
|
||||||
activeRequests = Math.max(0, activeRequests - 1);
|
activeRequests = Math.max(0, activeRequests - 1);
|
||||||
|
activeByDownload.set(downloadKey, Math.max(0, (activeByDownload.get(downloadKey) || 1) - 1));
|
||||||
};
|
};
|
||||||
response.once("close", finish);
|
response.once("close", finish);
|
||||||
response.writeHead(206, {
|
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")
|
proxies.map((proxy, index) => `shared${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||||
);
|
);
|
||||||
const commonOptions = {
|
const commonOptions = {
|
||||||
directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin`,
|
|
||||||
proxyListPath: proxyFile,
|
proxyListPath: proxyFile,
|
||||||
connections: 4,
|
connections: 4,
|
||||||
totalConnectionLimit: 4,
|
totalConnectionLimit: 4,
|
||||||
@@ -274,8 +324,8 @@ describe("proxy segmented download", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [firstResult, secondResult] = await Promise.all([
|
const [firstResult, secondResult] = await Promise.all([
|
||||||
downloadWithProxySegments({ ...commonOptions, targetPath: firstTarget }),
|
downloadWithProxySegments({ ...commonOptions, directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin?download=first`, targetPath: firstTarget }),
|
||||||
downloadWithProxySegments({ ...commonOptions, targetPath: secondTarget })
|
downloadWithProxySegments({ ...commonOptions, directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin?download=second`, targetPath: secondTarget })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(firstResult.status).toBe("completed");
|
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(firstTarget)).toEqual(content);
|
||||||
expect(await fs.promises.readFile(secondTarget)).toEqual(content);
|
expect(await fs.promises.readFile(secondTarget)).toEqual(content);
|
||||||
expect(maxActiveRequests).toBeLessThanOrEqual(4);
|
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[0].value).toBe(0);
|
||||||
expect(counters.every((counter) => counter.maxActive <= 1)).toBe(true);
|
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 () => {
|
it("reports an origin HTTP status instead of marking reachable proxies unavailable", async () => {
|
||||||
const server = http.createServer((_request, response) => {
|
const server = http.createServer((_request, response) => {
|
||||||
response.writeHead(503, { "Content-Type": "text/plain", "Content-Length": 19 });
|
response.writeHead(503, { "Content-Type": "text/plain", "Content-Length": 19 });
|
||||||
|
|||||||
@@ -613,6 +613,10 @@ describe("settings storage", () => {
|
|||||||
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
|
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", () => {
|
it("migrates the previous private update repository to the public release repository", () => {
|
||||||
for (const updateRepo of [
|
for (const updateRepo of [
|
||||||
"legacy-owner/real-debrid-downloader",
|
"legacy-owner/real-debrid-downloader",
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function createSettings(): AppSettings {
|
|||||||
proxyDownloadEnabled: false,
|
proxyDownloadEnabled: false,
|
||||||
proxyListPath: "",
|
proxyListPath: "",
|
||||||
proxyApiProxyIndex: 1,
|
proxyApiProxyIndex: 1,
|
||||||
proxyConnectionsPerDownload: 16,
|
proxyConnectionsPerDownload: 20,
|
||||||
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
|
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
|
||||||
autoUpdateCheck: true,
|
autoUpdateCheck: true,
|
||||||
clipboardWatch: true,
|
clipboardWatch: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user