From efa0909e11741f31b209b24ee6ead8a38e96fb77 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 8 Mar 2026 18:14:17 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Download=20System=20v2=20=E2=80=94=20co?= =?UTF-8?q?mplete=20rewrite=20of=20download=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace monolithic download-manager.ts (9500 lines) with 7 focused modules: - error-classifier.ts: 25+ typed DownloadErrorKind enum, classifier functions for network/HTTP/debrid/extraction errors — no more string matching - retry-manager.ts: Declarative per-error-kind retry policies, exponential backoff, shelving after 15 failures, state export/import - stream-writer.ts: HTTP stream → file with pre-resume validation, stall detection, NTFS-aligned buffered writing, Range-ignored detection - pipeline.ts: Single download lifecycle (unrestrict → stream → verify), throws typed errors, caller decides retry strategy - post-processor.ts: Extraction state machine with hard caps (3 attempts per archive, 5 rounds per package), no infinite loops - scheduler.ts: Queue management with priority-based slot allocation, heartbeat stall detection, global watchdog, provider cooldowns - download-manager.ts: Drop-in orchestrator (~1500 lines), same public API Fixes: 1. Hanging downloads: heartbeat-based stall detection + global watchdog 2. Wrong error classification: typed enum at point of origin 3. Unreliable resume: file size vs tracker validation, Range-ignored detection 4. Extraction loops: bounded retries with state machine 215 new unit tests for error-classifier and retry-manager (all passing). Build compiles cleanly. Same IPC interface — UI unchanged. Co-Authored-By: Claude Opus 4.6 --- .../2026-03-08-download-system-v2-design.md | 259 +++ .../2026-03-08-download-system-v2-plan.md | 737 ++++++++ src/main/app-controller.ts | 2 +- src/main/debug-server.ts | 2 +- src/main/download/download-manager.ts | 1603 +++++++++++++++++ src/main/download/error-classifier.ts | 508 ++++++ src/main/download/index.ts | 7 + src/main/download/pipeline.ts | 314 ++++ src/main/download/post-processor.ts | 409 +++++ src/main/download/retry-manager.ts | 390 ++++ src/main/download/scheduler.ts | 492 +++++ src/main/download/stream-writer.ts | 732 ++++++++ tests/error-classifier.test.ts | 705 ++++++++ tests/retry-manager.test.ts | 812 +++++++++ 14 files changed, 6970 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-03-08-download-system-v2-design.md create mode 100644 docs/plans/2026-03-08-download-system-v2-plan.md create mode 100644 src/main/download/download-manager.ts create mode 100644 src/main/download/error-classifier.ts create mode 100644 src/main/download/index.ts create mode 100644 src/main/download/pipeline.ts create mode 100644 src/main/download/post-processor.ts create mode 100644 src/main/download/retry-manager.ts create mode 100644 src/main/download/scheduler.ts create mode 100644 src/main/download/stream-writer.ts create mode 100644 tests/error-classifier.test.ts create mode 100644 tests/retry-manager.test.ts diff --git a/docs/plans/2026-03-08-download-system-v2-design.md b/docs/plans/2026-03-08-download-system-v2-design.md new file mode 100644 index 0000000..4e8c37a --- /dev/null +++ b/docs/plans/2026-03-08-download-system-v2-design.md @@ -0,0 +1,259 @@ +# Download System v2 — Complete Redesign + +## Goal +Replace the 9500-line monolithic `download-manager.ts` with a clean, modular download system that fixes: +1. Downloads hanging without clean restart +2. Wrong error classification leading to wrong retry paths +3. Unreliable resume (corrupt files, unnecessary restarts) +4. Post-processing (extraction) breaking or looping + +## Constraints +- Same IPC interface — drop-in replacement, no UI changes needed +- Same external dependencies (debrid.ts, storage.ts, integrity.ts) +- Same session/settings persistence format + +## Architecture + +### Module Structure + +``` +src/main/download/ +├── download-manager.ts # Orchestrator (~500 lines) — coordination only +├── scheduler.ts # Queue management, slot allocation, priorities +├── pipeline.ts # Single download flow: unrestrict → stream → verify +├── stream-writer.ts # HTTP streaming, resume, buffered writing, NTFS +├── error-classifier.ts # Typed error system (enums, not string matching) +├── retry-manager.ts # Central retry logic, backoff, shelving, state +└── post-processor.ts # Extraction queue, hybrid retry, cleanup +``` + +### Module Responsibilities + +#### 1. download-manager.ts (Orchestrator) +- Holds session state, packages, items +- Exposes same IPC methods as current (startRun, stopRun, pauseItem, etc.) +- Delegates to Scheduler for queue management +- Delegates to Pipeline for individual downloads +- Delegates to PostProcessor for extraction +- Emits same events as current (progress, status changes) +- Handles persistence (save/load session) + +#### 2. scheduler.ts +- `findNextItem()`: priority-based queue with provider cooldown awareness +- `fillSlots()`: start downloads up to maxParallel +- Scheduler loop with generation guard (prevents stale schedulers) +- Global stall watchdog +- Provider cooldown tracking (circuit breaker) +- AllDebrid paced-start / hoster-limit logic + +#### 3. pipeline.ts +- `runDownload(item, context)`: single download lifecycle +- Step 1: Unrestrict link via debrid service +- Step 2: Stream file via StreamWriter +- Step 3: Verify integrity (CRC if available) +- Step 4: Signal completion +- Each step returns typed result or throws typed DownloadError +- No retry logic here — just reports what happened + +#### 4. stream-writer.ts +- `streamToFile(url, targetPath, options)`: HTTP streaming +- Resume support with pre-validation: + - Check existing file size against tracked downloadedBytes + - Truncate if sparse file detected (pre-allocated > actual) + - Send Range header only after validation +- HTTP 416 handling (complete vs incomplete) +- Server-ignored-range detection (200 instead of 206) +- Buffered writing with NTFS 4KB alignment +- Sparse file pre-allocation (Windows) +- Content-Disposition filename override +- Stall detection (configurable timeout, default 10s) +- Drain timeout for slow disks (default 5min) +- Progress reporting via callback + +#### 5. error-classifier.ts +- `DownloadErrorKind` enum with all error categories +- `DownloadError` class extending Error with `.kind` property +- `classifyError(error, context)`: takes raw error + context, returns DownloadError + - Classifies at point of origin (HTTP layer, fetch layer, debrid layer) + - No post-hoc string matching needed +- `classifyHttpStatus(status, headers)`: HTTP-specific classification +- `classifyFetchError(error)`: network-level classification +- `classifyUnrestrictError(error)`: debrid-specific classification + +```typescript +enum DownloadErrorKind { + // Network + NetworkReset, // ECONNRESET, socket hang up, EPIPE + Timeout, // No data received within stall timeout + DnsFailure, // ENOTFOUND + + // HTTP + RangeNotSatisfied, // 416 — file may be complete or need restart + RangeIgnored, // Server sent 200 instead of 206 + ServerError, // 500, 502, 503 + RateLimited, // 429 + Forbidden, // 403 — link expired + NotFound, // 404 — file removed from CDN + + // Provider/Debrid + UnrestrictFailed, // Provider can't convert link + ProviderBusy, // Concurrent download limit + ProviderDown, // Provider service unavailable + HosterUnavailable, // Hoster down (not provider issue) + LinkDead, // Permanent: file deleted at source + QuotaExceeded, // Daily traffic limit + + // Filesystem + DiskFull, // ENOSPC + PermissionDenied, // EACCES, EPERM + FileLocked, // EBUSY (Windows) + + // Integrity + FileCorrupt, // CRC/size mismatch after download + FileTruncated, // Downloaded less than expected + + // Extraction + WrongPassword, // Archive password incorrect + ArchiveCorrupt, // Archive header/data damaged + ExtractorCrash, // 7-Zip/WinRAR process crashed + ExtractionLoop, // Same archive failed extraction 3+ times +} +``` + +#### 6. retry-manager.ts +- `RetryManager` class holds all retry state per item +- Deklarative retry policies per DownloadErrorKind: + +```typescript +interface RetryPolicy { + maxRetries: number; // 0 = no retry (permanent failure) + backoff: "fixed" | "exponential" | "linear"; + baseDelayMs: number; + maxDelayMs: number; + resetFile: boolean; // Delete partial file before retry + switchProvider: boolean; // Try different provider + refreshLink: boolean; // Get new direct link from debrid + providerCooldownMs?: number; // Apply cooldown to current provider +} +``` + +- `shouldRetry(itemId, error)`: returns { retry: boolean, delayMs, actions[] } +- `recordFailure(itemId, error)`: tracks failure for shelving +- Shelving: after N total failures (configurable, default 15), pause 90s + reset provider +- State persists across stop/start (same format as current retryStateByItem) +- `resetItem(itemId)`: clear all retry state (manual reset) + +#### 7. post-processor.ts +- `PostProcessor` class with extraction queue +- State machine per package: + ``` + pending → extracting → done + ↓ + retry (max 2) → failed + ``` +- Tracks extraction attempts per archive (max 3 retries) +- No infinite loops: hard cap on retry count +- Hybrid extract retry: if archive corrupt + redownload suggested, queue redownload (max 1 time) +- Cleanup: remove partial extracts on failure +- Empty folder cleanup after successful extraction + +### Data Flow + +``` +User clicks Start + ↓ +DownloadManager.startRun() + ↓ +Scheduler.start() — begins loop + ↓ +Scheduler.findNextItem() — picks highest priority queued item + ↓ +Pipeline.runDownload(item) + ├── debridService.unrestrict(item.link) + │ └── error? → ErrorClassifier.classify() → DownloadError + ├── StreamWriter.streamToFile(url, path, opts) + │ ├── Resume validation + │ ├── HTTP streaming with stall detection + │ └── error? → ErrorClassifier.classify() → DownloadError + └── integrityCheck(file) + └── error? → DownloadError(FileCorrupt) + ↓ +Success → mark completed → Scheduler fills next slot +Error → RetryManager.shouldRetry(item, error) + ├── retry: true → Scheduler.queueRetry(item, delay, actions) + └── retry: false → mark failed + ↓ +All items done → PostProcessor.run(package) + ├── Extract archives + ├── Verify extracted files + └── Cleanup +``` + +### Resume Validation (Key Improvement) + +Current problem: Resume trusts file size blindly, leading to corrupt files. + +New approach: +1. Before sending Range header, validate existing file: + - `stat.size` must match `item.downloadedBytes` (±1KB tolerance for flush timing) + - If mismatch > 1MB: file is from sparse pre-allocation → truncate to downloadedBytes + - If mismatch < 1MB but > 1KB: suspicious → delete and restart fresh +2. After resume response, validate: + - 206 with correct Content-Range → continue + - 200 (range ignored) → classify as RangeIgnored, retry with fresh link + - 416 → check if file actually complete (existingBytes >= expectedTotal) +3. After download complete, validate: + - Final file size matches expected total + - CRC check if manifest available + +### Stall Detection (Key Improvement) + +Current problem: Downloads hang and stall detection sometimes doesn't trigger properly. + +New approach: +- **Per-download heartbeat**: StreamWriter emits heartbeat every second with bytes received +- **Scheduler monitors heartbeats**: if no heartbeat for stallTimeoutMs → abort + retry +- **Disk-write awareness**: separate tracking for "blocked on disk write" vs "blocked on network" +- **Global watchdog**: if ALL active downloads show zero progress for 60s (excluding disk-blocked), abort all and re-queue +- **Validating timeout**: if unrestrict takes > 30s, abort and retry (prevents infinite hang in validation phase) + +### Post-Processing State Machine (Key Improvement) + +Current problem: Extraction can loop infinitely if archive keeps failing. + +New approach: +``` +ExtractionState per archive: +{ + archivePath: string; + status: "pending" | "extracting" | "done" | "failed"; + attempts: number; // max 3 + lastError?: string; + redownloaded: boolean; // max 1 redownload +} +``` + +Rules: +- Max 3 extraction attempts per archive +- If `ArchiveCorrupt` + `redownloaded === false` → queue redownload, set redownloaded = true +- If `ArchiveCorrupt` + `redownloaded === true` → fail permanently +- If `WrongPassword` → try next password from list, fail after all exhausted +- If `ExtractorCrash` → retry once, fail on second crash +- Package marked as "completed with errors" if any archive fails permanently + +## Migration Strategy + +1. New code lives in `src/main/download/` directory +2. Old `src/main/download-manager.ts` stays untouched as reference +3. New `download-manager.ts` in `src/main/download/` implements same class interface +4. Switch import in `main.ts` from old to new +5. Test with real downloads +6. Delete old file when stable + +## Testing Strategy + +- Unit tests for ErrorClassifier (classify every known error string) +- Unit tests for RetryManager (policy application, shelving threshold) +- Unit tests for StreamWriter resume validation logic +- Unit tests for PostProcessor state machine +- Integration test: Scheduler + Pipeline with mocked debrid/HTTP diff --git a/docs/plans/2026-03-08-download-system-v2-plan.md b/docs/plans/2026-03-08-download-system-v2-plan.md new file mode 100644 index 0000000..4a2e616 --- /dev/null +++ b/docs/plans/2026-03-08-download-system-v2-plan.md @@ -0,0 +1,737 @@ +# Download System v2 — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the 9500-line monolithic download-manager.ts with 7 clean modules that fix hanging downloads, wrong error classification, unreliable resume, and extraction loops. + +**Architecture:** Modular pipeline with typed errors, declarative retry policies, validated resume, and state-machine extraction. Same IPC interface — drop-in replacement. + +**Tech Stack:** TypeScript, Node.js, Electron IPC, EventEmitter + +--- + +### Task 1: Create error-classifier.ts — Typed Error System + +**Files:** +- Create: `src/main/download/error-classifier.ts` + +**Step 1: Create the DownloadErrorKind enum and DownloadError class** + +```typescript +// src/main/download/error-classifier.ts + +export enum DownloadErrorKind { + // Network + NetworkReset = "network_reset", + Timeout = "timeout", + DnsFailure = "dns_failure", + + // HTTP + RangeNotSatisfied = "range_not_satisfied", + RangeIgnored = "range_ignored", + ServerError = "server_error", + RateLimited = "rate_limited", + Forbidden = "forbidden", + NotFound = "not_found", + + // Provider/Debrid + UnrestrictFailed = "unrestrict_failed", + ProviderBusy = "provider_busy", + ProviderDown = "provider_down", + HosterUnavailable = "hoster_unavailable", + LinkDead = "link_dead", + QuotaExceeded = "quota_exceeded", + + // Filesystem + DiskFull = "disk_full", + PermissionDenied = "permission_denied", + FileLocked = "file_locked", + + // Integrity + FileCorrupt = "file_corrupt", + FileTruncated = "file_truncated", + ResumeUnderflow = "resume_underflow", + + // Extraction + WrongPassword = "wrong_password", + ArchiveCorrupt = "archive_corrupt", + ExtractorCrash = "extractor_crash", + + // Catchall + Unknown = "unknown", +} + +export class DownloadError extends Error { + readonly kind: DownloadErrorKind; + readonly retryable: boolean; + readonly permanent: boolean; + readonly httpStatus?: number; + readonly originalError?: Error; + + constructor(kind: DownloadErrorKind, message: string, opts?: { + httpStatus?: number; + originalError?: Error; + retryable?: boolean; + permanent?: boolean; + }) { + super(message); + this.name = "DownloadError"; + this.kind = kind; + this.retryable = opts?.retryable ?? !isPermanentKind(kind); + this.permanent = opts?.permanent ?? isPermanentKind(kind); + this.httpStatus = opts?.httpStatus; + this.originalError = opts?.originalError; + } +} +``` + +**Step 2: Create classifyFetchError — network-level errors** + +Classify raw fetch/network errors at the point they occur. Covers ECONNRESET, socket hang up, DNS, timeout, ENOSPC, EACCES, EPERM, EBUSY. + +**Step 3: Create classifyHttpStatus — HTTP response errors** + +Classify HTTP status codes: 416 → RangeNotSatisfied, 429 → RateLimited, 403 → Forbidden, 404 → NotFound, 5xx → ServerError. Also detect range-ignored (200 when Range was sent). + +**Step 4: Create classifyUnrestrictError — debrid API errors** + +Classify unrestrict response errors by checking for known patterns: +- "file not found", "file deleted", "link is dead" → LinkDead (permanent) +- "too many active", "concurrent limit" → ProviderBusy +- "hosternotavailable" → HosterUnavailable +- "server error", "maintenance", "cloudflare" → ProviderDown +- "quota", "traffic" → QuotaExceeded +- Everything else → UnrestrictFailed + +**Step 5: Create classifyExtractionError** + +Classify extraction failures: wrong_password → WrongPassword, corrupt header → ArchiveCorrupt, process crash → ExtractorCrash. + +**Step 6: Helper function isPermanentKind** + +Returns true for LinkDead, DiskFull, PermissionDenied — errors where retrying is pointless. + +--- + +### Task 2: Create retry-manager.ts — Declarative Retry Logic + +**Files:** +- Create: `src/main/download/retry-manager.ts` + +**Step 1: Define RetryPolicy interface and RETRY_POLICIES map** + +```typescript +export interface RetryPolicy { + maxRetries: number; // 0 = permanent failure, no retry + backoff: "fixed" | "exponential"; + baseDelayMs: number; + maxDelayMs: number; + resetFile: boolean; // Delete partial file before retry + switchProvider: boolean; // Try different debrid provider + refreshLink: boolean; // Get new direct link + providerCooldownMs: number; // Apply cooldown to current provider (0 = no cooldown) +} + +export const RETRY_POLICIES: Record = { + [DownloadErrorKind.NetworkReset]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.Timeout]: { maxRetries: 10, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 30000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.DnsFailure]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 5000, maxDelayMs: 5000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.RangeNotSatisfied]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 200, maxDelayMs: 200, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.RangeIgnored]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.ServerError]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.RateLimited]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.Forbidden]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.NotFound]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.UnrestrictFailed]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 20000 }, + [DownloadErrorKind.ProviderBusy]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 60000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 12000 }, + [DownloadErrorKind.ProviderDown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 10000, maxDelayMs: 180000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 30000 }, + [DownloadErrorKind.HosterUnavailable]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 30000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 15000 }, + [DownloadErrorKind.LinkDead]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.QuotaExceeded]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 30000, maxDelayMs: 300000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 60000 }, + [DownloadErrorKind.DiskFull]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.PermissionDenied]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.FileLocked]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 10000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.FileCorrupt]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 500, maxDelayMs: 500, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.FileTruncated]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.ResumeUnderflow]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.WrongPassword]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.ArchiveCorrupt]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, + [DownloadErrorKind.ExtractorCrash]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, + [DownloadErrorKind.Unknown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, +}; +``` + +**Step 2: Create RetryManager class** + +```typescript +export interface RetryState { + failuresByKind: Partial>; + totalFailures: number; + shelveCount: number; + lastError?: DownloadError; +} + +export interface RetryDecision { + shouldRetry: boolean; + delayMs: number; + actions: RetryAction[]; + reason: string; // Human-readable status message +} + +export type RetryAction = "reset_file" | "switch_provider" | "refresh_link" | "cooldown_provider" | "shelve"; + +export class RetryManager { + private states: Map = new Map(); + private userRetryLimit: number = 0; // 0 = unlimited + + constructor(retryLimit: number); + setRetryLimit(limit: number): void; + + /** + * Record a failure and decide whether to retry. + */ + evaluate(itemId: string, error: DownloadError): RetryDecision; + + /** + * Reset retry state for an item (manual reset). + */ + resetItem(itemId: string): void; + + /** + * Get current retry state (for persistence). + */ + getState(itemId: string): RetryState | undefined; + + /** + * Restore retry state (from persisted session). + */ + restoreState(itemId: string, state: RetryState): void; + + /** + * Export all states for persistence. + */ + exportStates(): Record; + + /** + * Import states from persistence. + */ + importStates(states: Record): void; + + /** + * Remove state for deleted items. + */ + removeItem(itemId: string): void; +} +``` + +Key logic: +- `evaluate()` checks policy for error.kind, compares against current failure count +- If totalFailures >= 15 (SHELVE_THRESHOLD): shelve (90s pause + half-reset counters + switch provider) +- User retryLimit overrides policy maxRetries if set (retryLimit > 0) +- Backoff calculation: exponential = baseDelayMs * 1.5^(attempt-1) with jitter, capped at maxDelayMs +- Returns structured RetryDecision with all actions the caller needs to execute + +--- + +### Task 3: Create stream-writer.ts — HTTP Streaming with Validated Resume + +**Files:** +- Create: `src/main/download/stream-writer.ts` + +**Step 1: Define StreamResult and StreamOptions interfaces** + +```typescript +export interface StreamOptions { + url: string; + targetPath: string; + expectedBytes: number | null; + downloadedBytes: number; // Previously downloaded (for resume validation) + stallTimeoutMs: number; + connectTimeoutMs: number; + skipTlsVerify: boolean; + speedLimitBps: number; // 0 = no limit + signal: AbortSignal; + onProgress: (bytes: number, totalBytes: number | null, speedBps: number) => void; + onHeartbeat: () => void; // Called every ~1s even during slow transfer + onResumable: (resumable: boolean) => void; + onFileNameOverride: (newName: string) => void; // Content-Disposition +} + +export interface StreamResult { + totalBytes: number; + downloadedBytes: number; + resumable: boolean; + fileName?: string; // If Content-Disposition provided new name + completed: boolean; +} +``` + +**Step 2: Implement streamToFile function** + +Core logic: +1. **Pre-resume validation**: stat existing file, compare with downloadedBytes + - If file doesn't exist → fresh download (downloadedBytes = 0) + - If file.size matches downloadedBytes (±4KB) → resume from file.size + - If file.size > downloadedBytes + 1MB → truncate to downloadedBytes (sparse file fix) + - If file.size < downloadedBytes (but > 0) → file was corrupted, delete and restart +2. **HTTP request**: send Range header if resuming, detect 206/200/416 +3. **416 handling**: check Content-Range for total, if file complete → accept, else throw RangeNotSatisfied +4. **200 with Range sent**: throw RangeIgnored +5. **Streaming loop**: buffered read with stall timeout, write with NTFS alignment, backpressure handling +6. **Heartbeat**: emit heartbeat every 1s regardless of transfer state +7. **Speed limiting**: token bucket or simple delay between chunks +8. **Content-Disposition**: parse filename, notify via callback +9. **Sparse pre-allocation**: on Windows, pre-allocate file with truncate for fresh downloads + +**Step 3: Implement stall detection within the stream loop** + +- Read with timeout (stallTimeoutMs) +- If timeout → throw DownloadError(Timeout) +- Track blockedOnDiskWrite state (write backpressure) +- Drain timeout for slow disks (5 min) + +**Step 4: Implement the buffered writer with NTFS alignment** + +- 512KB write buffer +- Flush aligned to 4KB boundaries +- Final flush writes remaining bytes +- Backpressure: await stream.drain() when write returns false + +--- + +### Task 4: Create pipeline.ts — Single Download Lifecycle + +**Files:** +- Create: `src/main/download/pipeline.ts` + +**Step 1: Define PipelineContext and PipelineResult** + +```typescript +export interface PipelineContext { + item: DownloadItem; + package: PackageEntry; + settings: AppSettings; + debridService: DebridService; + signal: AbortSignal; + cachedDirectUrl?: string; // Reuse from previous attempt + onStatus: (status: DownloadStatus, fullStatus: string) => void; + onProgress: (bytes: number, total: number | null, speed: number) => void; + onResumable: (resumable: boolean) => void; + onFileNameOverride: (newName: string) => void; + onProviderInfo: (provider: DebridProvider, label?: string, accountId?: string, accountLabel?: string) => void; + onHeartbeat: () => void; +} + +export interface PipelineResult { + success: boolean; + downloadedBytes: number; + totalBytes: number | null; + directUrl?: string; // For caching across retries + resumable: boolean; +} +``` + +**Step 2: Implement runPipeline function** + +```typescript +export async function runPipeline(ctx: PipelineContext): Promise +``` + +Steps within the pipeline: +1. **Unrestrict**: Call debridService.unrestrict() with abort signal, apply TLS skip if needed + - On error → classifyUnrestrictError() → throw DownloadError + - On success → emit provider info, update status to "downloading" +2. **Stream**: Call streamToFile() with resolved direct URL + - On error → classifyFetchError() or classifyHttpStatus() → throw DownloadError + - On progress → forward to ctx.onProgress +3. **Integrity check** (if enabled): Call validateFileAgainstManifest() + - On mismatch → throw DownloadError(FileCorrupt) +4. Return PipelineResult with final state + +The pipeline does NOT handle retries — it runs once and either succeeds or throws a typed DownloadError. The caller (download-manager + retry-manager) decides what to do with errors. + +--- + +### Task 5: Create post-processor.ts — Extraction State Machine + +**Files:** +- Create: `src/main/download/post-processor.ts` + +**Step 1: Define extraction state types** + +```typescript +export interface ArchiveExtractionState { + archiveName: string; + status: "pending" | "extracting" | "done" | "failed"; + attempts: number; + maxAttempts: number; // Default 3 + redownloaded: boolean; + lastError?: string; + lastErrorKind?: DownloadErrorKind; +} + +export interface PackagePostProcessState { + packageId: string; + status: "pending" | "extracting" | "done" | "failed" | "aborted"; + archives: Map; + startedAt: number; + completedAt?: number; +} +``` + +**Step 2: Implement PostProcessor class** + +```typescript +export class PostProcessor extends EventEmitter { + private states: Map = new Map(); + private abortControllers: Map = new Map(); + private activeCount: number = 0; + private maxParallel: number; + + constructor(maxParallel: number); + + /** + * Queue a package for post-processing (extraction). + */ + queuePackage(packageId: string, options: PostProcessOptions): void; + + /** + * Run the post-processing queue. + */ + async processQueue(): Promise; + + /** + * Abort processing for a specific package. + */ + abortPackage(packageId: string): void; + + /** + * Abort all active post-processing. + */ + abortAll(): void; + + /** + * Retry extraction for a specific package. + */ + retryPackage(packageId: string): void; + + /** + * Get state for a package. + */ + getState(packageId: string): PackagePostProcessState | undefined; + + /** + * Check if any processing is active. + */ + isActive(): boolean; + + // Events: + // "progress" → { packageId, update: ExtractProgressUpdate } + // "package-done" → { packageId, success: boolean, errors: string[] } + // "archive-redownload" → { packageId, archiveName } + // "status" → { packageId, label: string } +} +``` + +Key rules: +- Max 3 extraction attempts per archive +- If ArchiveCorrupt + not yet redownloaded → emit "archive-redownload", set redownloaded=true +- If ArchiveCorrupt + already redownloaded → fail permanently +- If WrongPassword → try all passwords in list, then fail +- If ExtractorCrash → retry once, then fail +- Package "done" only when ALL archives are done or permanently failed +- Package "failed" if ANY archive failed permanently +- No infinite loops possible (hard cap on attempts) + +--- + +### Task 6: Create scheduler.ts — Queue Management & Slot Allocation + +**Files:** +- Create: `src/main/download/scheduler.ts` + +**Step 1: Define scheduler types** + +```typescript +export interface SchedulerConfig { + maxParallel: number; + stallTimeoutMs: number; + globalStallWatchdogMs: number; + allDebridStaggerMs: number; +} + +export interface SlotRequest { + itemId: string; + packageId: string; +} + +export interface ProviderCooldown { + provider: string; + cooldownUntil: number; + failureCount: number; +} +``` + +**Step 2: Implement Scheduler class** + +```typescript +export class Scheduler extends EventEmitter { + private generation: number = 0; + private running: boolean = false; + private paused: boolean = false; + + // Active download tracking + private activeSlots: Map = new Map(); + + // Provider cooldowns (circuit breaker) + private providerCooldowns: Map = new Map(); + + // Retry delays per item + private retryDelays: Map = new Map(); // itemId → retryAfterEpochMs + + constructor(config: SchedulerConfig); + + /** + * Start the scheduler loop. + */ + async start(findNextItem: () => SlotRequest | null, startItem: (slot: SlotRequest) => void): Promise; + + /** + * Stop the scheduler (bumps generation to kill old loop). + */ + stop(): void; + + /** + * Pause/unpause slot allocation. + */ + setPaused(paused: boolean): void; + + /** + * Register an item as actively downloading. + */ + claimSlot(itemId: string, packageId: string): void; + + /** + * Release a slot (download finished/failed/cancelled). + */ + releaseSlot(itemId: string): void; + + /** + * Record heartbeat from active download. + */ + heartbeat(itemId: string, downloadedBytes: number): void; + + /** + * Schedule a retry delay for an item. + */ + scheduleRetry(itemId: string, delayMs: number): void; + + /** + * Check if an item is delayed (retry pending). + */ + isDelayed(itemId: string): boolean; + + /** + * Apply provider cooldown. + */ + applyProviderCooldown(provider: string, cooldownMs: number): void; + + /** + * Check if provider is in cooldown. + */ + getProviderCooldownRemaining(provider: string): number; + + /** + * Get number of active slots. + */ + get activeCount(): number; + + /** + * Check if scheduler has capacity for more downloads. + */ + hasCapacity(): boolean; + + // Events: + // "stall-detected" → { itemId } (per-item stall from heartbeat monitoring) + // "global-stall" → { itemIds: string[] } (all downloads stalled) + // "run-complete" → {} (no more items to process) +} +``` + +Key logic: +- Scheduler loop runs at 120ms intervals, checking for available slots +- Global stall watchdog: if zero bytes across ALL downloads for globalStallWatchdogMs → emit "global-stall" +- Per-item heartbeat monitoring: if no heartbeat for stallTimeoutMs → emit "stall-detected" +- Provider cooldowns: checked in findNextItem filter +- Retry delays: checked in findNextItem filter +- Generation guard: stop() bumps generation, old loop exits + +--- + +### Task 7: Create download-manager.ts — Orchestrator (Drop-in Replacement) + +**Files:** +- Create: `src/main/download/download-manager.ts` +- Create: `src/main/download/index.ts` (re-export) + +**Step 1: Create the DownloadManager class with same constructor signature** + +Same constructor as current: `(settings, session, storagePaths, options?)`. Must extend EventEmitter. Must emit "state" events with UiSnapshot. + +**Step 2: Implement queue management methods** + +Port directly from old code (these are mostly unchanged): +- `addPackages()`, `clearAll()`, `exportQueue()`, `importQueue()` +- `renamePackage()`, `reorderPackages()`, `togglePackage()`, `cancelPackage()`, `resetPackage()` +- `setPackagePriority()`, `removeItem()`, `skipItems()`, `resetItems()` +- `getSnapshot()`, `getStats()`, `getSessionStats()` + +**Step 3: Implement start/stop/pause using new Scheduler** + +- `start()`: create Scheduler, RetryManager, and begin processing +- `stop()`: stop Scheduler, abort all active pipelines, persist retry state +- `togglePause()`: delegate to Scheduler.setPaused() + +**Step 4: Wire up Pipeline execution** + +When Scheduler requests a new download: +1. Create AbortController for the item +2. Call `runPipeline()` with item context +3. On success → mark completed, release slot, trigger post-processing if package done +4. On DownloadError → call `RetryManager.evaluate()` + - If shouldRetry: execute actions (reset file, switch provider, etc.), schedule retry delay + - If !shouldRetry: mark failed + +**Step 5: Wire up PostProcessor** + +- Listen for "package-done" → update package status, trigger cleanup, add history entry +- Listen for "archive-redownload" → re-queue download item +- Listen for "progress" → forward extraction progress to UI + +**Step 6: Wire up Scheduler events** + +- "stall-detected" → abort the stalled download, retry via RetryManager +- "global-stall" → abort all, re-queue all active items +- "run-complete" → finalize session, create summary + +**Step 7: Implement persistence** + +- Same debounced persistSoon() / persistNow() pattern +- RetryManager states persisted alongside session +- PostProcessor states persisted alongside session + +**Step 8: Implement speed/ETA calculation** + +Port from old code: moving window speed, per-package speed, ETA calculation. + +**Step 9: Implement reconnect handling** + +Port 429/503 reconnect logic using new error types (RateLimited → reconnect wait). + +**Step 10: Create index.ts re-export** + +```typescript +// src/main/download/index.ts +export { DownloadManager } from "./download-manager"; +``` + +--- + +### Task 8: Integration — Switch Import & Test + +**Files:** +- Modify: `src/main/app-controller.ts` — change import path +- Keep: `src/main/download-manager.ts` — old file stays as reference + +**Step 1: Update import in app-controller.ts** + +Change from: +```typescript +import { DownloadManager } from "./download-manager"; +``` +To: +```typescript +import { DownloadManager } from "./download/download-manager"; +``` + +**Step 2: Build and verify** + +```bash +npm run build +``` + +Fix any TypeScript compilation errors. + +**Step 3: Run existing tests** + +```bash +npx vitest run --reporter=verbose tests/utils.test.ts tests/storage.test.ts tests/integrity.test.ts tests/cleanup.test.ts tests/extractor.test.ts tests/debrid.test.ts tests/update.test.ts tests/auto-rename.test.ts +``` + +All should still pass since we didn't change the external modules. + +--- + +### Task 9: Write Unit Tests for New Modules + +**Files:** +- Create: `tests/error-classifier.test.ts` +- Create: `tests/retry-manager.test.ts` + +**Step 1: Test error classification** + +Test that every known error string maps to the correct DownloadErrorKind: +- "socket hang up" → NetworkReset +- "ECONNRESET" → NetworkReset +- "file not found" → LinkDead +- "too many active" → ProviderBusy +- HTTP 416 → RangeNotSatisfied +- HTTP 429 → RateLimited +- etc. + +**Step 2: Test retry decisions** + +- NetworkReset: retries 3 times with 300ms delay, then fails +- LinkDead: fails immediately (maxRetries = 0) +- ProviderBusy: retries with exponential backoff, switches provider +- After 15 total failures: shelve (90s delay, halved counters) +- User retryLimit override works + +**Step 3: Test shelving logic** + +- Accumulate 15 failures across different kinds +- Verify counters are halved +- Verify 90s delay applied +- Verify provider switch requested + +--- + +### Task 10: Cleanup & Finalize + +**Step 1: Verify full build** + +```bash +npm run build +``` + +**Step 2: Run all fast tests** + +```bash +npx vitest run --reporter=verbose +``` + +**Step 3: Remove old download-manager.ts** (only after confirming stability) + +**Step 4: Commit** + +```bash +git add src/main/download/ tests/error-classifier.test.ts tests/retry-manager.test.ts +git commit -m "feat: replace monolithic download-manager with modular download system v2 + +- Split 9500-line download-manager.ts into 7 focused modules +- Add typed error classification (DownloadErrorKind enum) +- Add declarative retry policies per error type +- Add validated resume (pre-check file integrity before Range header) +- Add extraction state machine (max 3 retries, no infinite loops) +- Same IPC interface — drop-in replacement" +``` diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 96266e8..9c5f7ea 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -21,7 +21,7 @@ import { import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits"; import { importDlcContainers } from "./container"; import { APP_VERSION } from "./constants"; -import { DownloadManager } from "./download-manager"; +import { DownloadManager } from "./download/download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 204ed7e..2979b99 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -2,7 +2,7 @@ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { logger, getLogFilePath } from "./logger"; -import type { DownloadManager } from "./download-manager"; +import type { DownloadManager } from "./download/download-manager"; const DEFAULT_PORT = 9868; const MAX_LOG_LINES = 10000; diff --git a/src/main/download/download-manager.ts b/src/main/download/download-manager.ts new file mode 100644 index 0000000..37ef952 --- /dev/null +++ b/src/main/download/download-manager.ts @@ -0,0 +1,1603 @@ +/** + * download-manager.ts — Orchestrator (drop-in replacement). + * + * Same public API as the old monolithic download-manager.ts. + * Delegates to: Scheduler, Pipeline, RetryManager, PostProcessor, StreamWriter. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { v4 as uuidv4 } from "uuid"; +import { + AppSettings, + DownloadItem, + DownloadStats, + DownloadSummary, + DownloadStatus, + DuplicatePolicy, + HistoryEntry, + PackageEntry, + PackagePriority, + ParsedPackageInput, + SessionState, + StartConflictEntry, + StartConflictResolutionResult, + UiSnapshot, + BandwidthSample, + SessionStats, +} from "../../shared/types"; +import { parseDebridLinkApiKeys } from "../../shared/debrid-link-keys"; +import { + addDebridLinkApiKeyDailyUsageBytes, + addDebridLinkApiKeyTotalUsageBytes, + addProviderDailyUsageBytes, + addProviderTotalUsageBytes, + getProviderUsageDayKey, + isProviderDailyLimitReached, +} from "../../shared/provider-daily-limits"; +import { SPEED_WINDOW_SECONDS } from "../constants"; +import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../cleanup"; +import { DebridService, type AllDebridWebUnrestrictor, type BestDebridWebUnrestrictor, type MegaWebUnrestrictor, type RealDebridWebUnrestrictor } from "../debrid"; +import { extractPackageArchives, findArchiveCandidates, clearExtractResumeState, removeEmptyDirectoryTree } from "../extractor"; +import { validateFileAgainstManifest } from "../integrity"; +import { logger } from "../logger"; +import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "../package-log"; +import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../storage"; +import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "../utils"; + +// New modules +import { DownloadError, DownloadErrorKind, ensureDownloadError, errorKindLabel, classifyUnrestrictError } from "./error-classifier"; +import { RetryManager, RETRY_POLICIES, type RetryDecision, type RetryAction } from "./retry-manager"; +import { streamToFile } from "./stream-writer"; +import { runPipeline, type PipelineResult, type PipelineContext } from "./pipeline"; +import { PostProcessor, type PostProcessOptions, type ExtractProgressUpdate } from "./post-processor"; +import { Scheduler, type ActiveSlot, type SlotRequest } from "./scheduler"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DownloadManagerOptions = { + megaWebUnrestrict?: MegaWebUnrestrictor; + allDebridWebUnrestrict?: AllDebridWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; + bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; + invalidateMegaSession?: () => void; + onHistoryEntry?: (entry: HistoryEntry) => void; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isFinishedStatus(status: DownloadStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function cloneSession(s: SessionState): SessionState { + return JSON.parse(JSON.stringify(s)); +} + +function cloneSettings(s: AppSettings): AppSettings { + return JSON.parse(JSON.stringify(s)); +} + +function pathKey(p: string): string { + return process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p); +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + return path.resolve(filePath).toLowerCase().startsWith(path.resolve(dirPath).toLowerCase()); +} + +function providerLabel(p: string | null): string { + if (!p) return "Debrid"; + const labels: Record = { + realdebrid: "Real-Debrid", + "megadebrid-api": "Mega-Debrid API", + "megadebrid-web": "Mega-Debrid Web", + megadebrid: "Mega-Debrid", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid", + ddownload: "DDownload", + onefichier: "1Fichier", + debridlink: "DebridLink", + linksnappy: "LinkSnappy", + }; + return labels[p] || p; +} + +// --------------------------------------------------------------------------- +// DownloadManager +// --------------------------------------------------------------------------- + +export class DownloadManager extends EventEmitter { + // Core state + private settings: AppSettings; + private session: SessionState; + private storagePaths: StoragePaths; + private debridService: DebridService; + private itemCount: number; + + // New modules + private scheduler: Scheduler; + private retryManager: RetryManager; + private postProcessor: PostProcessor; + + // Callbacks + private invalidateMegaSessionFn?: () => void; + private onHistoryEntryCallback?: (entry: HistoryEntry) => void; + + // Active downloads (maps to scheduler slots) + private activeTasks = new Map(); + + // Cached direct URLs for retries + private cachedDirectUrls = new Map(); + + // Target path reservations + private reservedTargetPaths = new Map(); // pathKey → itemId + private claimedTargetPathByItem = new Map(); // itemId → path + + // Speed tracking + private speedEvents: Array<{ at: number; bytes: number; pid: string }> = []; + private speedEventsHead = 0; + private speedBytesLastWindow = 0; + private speedBytesPerPackage = new Map(); + private lastSpeedPruneAt = 0; + + // Session tracking + private sessionDownloadedBytes = 0; + private sessionCompletedFiles = 0; + private itemContributedBytes = new Map(); + + // Summary + private summary: DownloadSummary | null = null; + + // Run scope + private runItemIds = new Set(); + private runPackageIds = new Set(); + private runOutcomes = new Map(); + + // History + private historyRecordedPackages = new Set(); + + // State emission + private stateEmitTimer: NodeJS.Timeout | null = null; + private lastStateEmitAt = 0; + + // Persistence + private persistTimer: NodeJS.Timeout | null = null; + private lastPersistAt = 0; + public blockAllPersistence = false; + public skipShutdownPersist = false; + + // Stats cache + private statsCache: DownloadStats | null = null; + private statsCacheAt = 0; + + // Reconnect + private consecutiveReconnects = 0; + private lastReconnectMarkAt = 0; + + // Post-processing tracking (hybrid) + private hybridExtractRequeue = new Set(); + private packagePostProcessTasks = new Map>(); + + constructor( + settings: AppSettings, + session: SessionState, + storagePaths: StoragePaths, + options: DownloadManagerOptions = {}, + ) { + super(); + this.settings = settings; + this.session = cloneSession(session); + this.itemCount = Object.keys(this.session.items).length; + this.storagePaths = storagePaths; + + this.debridService = new DebridService(settings, { + megaWebUnrestrict: options.megaWebUnrestrict, + allDebridWebUnrestrict: options.allDebridWebUnrestrict, + realDebridWebUnrestrict: options.realDebridWebUnrestrict, + bestDebridWebUnrestrict: options.bestDebridWebUnrestrict, + }); + + this.invalidateMegaSessionFn = options.invalidateMegaSession; + this.onHistoryEntryCallback = options.onHistoryEntry; + + // Initialize new modules + this.scheduler = new Scheduler({ + maxParallel: Math.max(1, settings.maxParallel), + stallTimeoutMs: 10_000, + globalStallWatchdogMs: 60_000, + }); + this.retryManager = new RetryManager(settings.retryLimit); + this.postProcessor = new PostProcessor(settings.maxParallelExtract); + + // Wire up scheduler events + this.scheduler.on("stall-detected", ({ itemId }: { itemId: string }) => { + this.handleStallDetected(itemId); + }); + this.scheduler.on("global-stall", ({ itemIds }: { itemIds: string[] }) => { + this.handleGlobalStall(itemIds); + }); + this.scheduler.on("run-complete", () => { + this.finishRun(); + }); + + // Wire up post-processor events + this.postProcessor.setExtractor(extractPackageArchives, findArchiveCandidates); + this.postProcessor.on("progress", ({ packageId, update }: { packageId: string; update: ExtractProgressUpdate }) => { + this.handleExtractProgress(packageId, update); + }); + this.postProcessor.on("package-done", ({ packageId, success, errors }: { packageId: string; success: boolean; errors: string[] }) => { + this.handlePostProcessDone(packageId, success, errors); + }); + this.postProcessor.on("archive-redownload", ({ packageId, archiveName, error }: { packageId: string; archiveName: string; error: string }) => { + this.handleArchiveRedownload(packageId, archiveName, error); + }); + this.postProcessor.on("status", ({ packageId, label }: { packageId: string; label: string }) => { + const pkg = this.session.packages[packageId]; + if (pkg) { + pkg.postProcessLabel = label; + this.emitState(); + } + }); + + logger.info(`DownloadManager v2 Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items`); + + // Restore target path reservations + this.restoreTargetPathReservations(); + // Normalize session statuses + this.normalizeSessionStatuses(); + } + + // ----------------------------------------------------------------------- + // Getters + // ----------------------------------------------------------------------- + + public getSettings(): AppSettings { return this.settings; } + public getSession(): SessionState { return this.session; } + public getSummary(): DownloadSummary | null { return this.summary; } + public isSessionRunning(): boolean { return this.session.running; } + + public getPackageLogPath(packageId: string): string | null { + return getPersistedPackageLogPath(packageId); + } + + public getSnapshot(): UiSnapshot { + const now = nowMs(); + this.pruneSpeedEvents(now); + const paused = this.session.running && this.session.paused; + const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS; + + let totalItems = 0; + let doneItems = 0; + if (this.session.running && this.runItemIds.size > 0) { + totalItems = this.runItemIds.size; + for (const itemId of this.runItemIds) { + if (this.runOutcomes.has(itemId)) { doneItems++; continue; } + const item = this.session.items[itemId]; + if (item && isFinishedStatus(item.status)) doneItems++; + } + } else { + const items = Object.values(this.session.items); + totalItems = items.length; + for (const item of items) { if (isFinishedStatus(item.status)) doneItems++; } + } + + const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0; + const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0; + const remaining = totalItems - doneItems; + const eta = remaining > 0 && rate > 0 ? remaining / rate : -1; + const reconnectMs = Math.max(0, this.session.reconnectUntil - now); + + return { + settings: cloneSettings(this.settings), + session: cloneSession(this.session), + summary: this.summary ? { ...this.summary } : null, + stats: this.getStats(now), + speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, + etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`, + canStart: !this.session.running, + canStop: this.session.running, + canPause: this.session.running, + clipboardActive: this.settings.clipboardWatch, + reconnectSeconds: Math.ceil(reconnectMs / 1000), + packageSpeedBps: !this.session.running || paused ? {} : Object.fromEntries( + [...this.speedBytesPerPackage].map(([pid, bytes]) => [pid, Math.floor(bytes / SPEED_WINDOW_SECONDS)]), + ), + }; + } + + public getStats(now = nowMs()): DownloadStats { + if (this.statsCache && this.session.running && this.itemCount >= 500 && now - this.statsCacheAt < 1500) { + return this.statsCache; + } + const stats: DownloadStats = { + totalDownloaded: this.sessionDownloadedBytes, + totalDownloadedAllTime: this.settings.totalDownloadedAllTime, + totalFilesSession: this.sessionCompletedFiles, + totalFilesAllTime: this.settings.totalCompletedFilesAllTime, + totalPackages: this.session.packageOrder.length, + sessionStartedAt: this.session.runStartedAt, + }; + this.statsCache = stats; + this.statsCacheAt = now; + return stats; + } + + public getSessionStats(): SessionStats { + const now = nowMs(); + this.pruneSpeedEvents(now); + const samples: BandwidthSample[] = []; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i++) { + const e = this.speedEvents[i]; + if (e) samples.push({ timestamp: e.at, speedBps: Math.floor(e.bytes * (1000 / 120)) }); + } + const paused = this.session.running && this.session.paused; + const currentSpeedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS; + let maxSpeed = 0; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i++) { + const e = this.speedEvents[i]; + if (e) { const s = Math.floor(e.bytes * (1000 / 120)); if (s > maxSpeed) maxSpeed = s; } + } + const sessionDurationSeconds = this.session.runStartedAt > 0 ? Math.max(0, Math.floor((now - this.session.runStartedAt) / 1000)) : 0; + const averageSpeedBps = sessionDurationSeconds > 0 ? Math.floor(this.session.totalDownloadedBytes / sessionDurationSeconds) : 0; + let total = 0, completed = 0, failed = 0, active = 0, queued = 0; + for (const item of Object.values(this.session.items)) { + total++; + if (item.status === "completed") completed++; + else if (item.status === "failed") failed++; + else if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") active++; + else if (item.status === "queued" || item.status === "reconnect_wait" || item.status === "paused") queued++; + } + return { + bandwidth: { samples: samples.slice(-120), currentSpeedBps: Math.floor(currentSpeedBps), averageSpeedBps, maxSpeedBps: Math.floor(maxSpeed), totalBytesSession: this.session.totalDownloadedBytes, sessionDurationSeconds }, + totalDownloads: total, completedDownloads: completed, failedDownloads: failed, activeDownloads: active, queuedDownloads: queued, + }; + } + + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + public setSettings(next: AppSettings): void { + this.settings = next; + this.debridService.setSettings(next); + this.scheduler.updateConfig({ maxParallel: Math.max(1, next.maxParallel) }); + this.retryManager.setRetryLimit(next.retryLimit); + this.postProcessor.setMaxParallel(next.maxParallelExtract); + this.emitState(); + } + + // ----------------------------------------------------------------------- + // Queue Management + // ----------------------------------------------------------------------- + + public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } { + let addedPackages = 0; + let addedLinks = 0; + for (const pkg of packages) { + const links = pkg.links.filter(l => !!l.trim()); + if (links.length === 0) continue; + const packageId = uuidv4(); + const safeName = sanitizeFilename(pkg.name); + const outputDir = ensureDirPath(this.settings.outputDir, safeName); + const extractBase = this.settings.extractDir || path.join(this.settings.outputDir, "_entpackt"); + const extractDir = this.settings.createExtractSubfolder ? ensureDirPath(extractBase, safeName) : extractBase; + const packageEntry: PackageEntry = { + id: packageId, name: safeName, outputDir, extractDir, status: "queued", + itemIds: [], cancelled: false, enabled: true, priority: "normal", + createdAt: nowMs(), updatedAt: nowMs(), + }; + for (let i = 0; i < links.length; i++) { + const link = links[i]; + const itemId = uuidv4(); + const hintName = pkg.fileNames?.[i]; + const fileName = (hintName && !looksLikeOpaqueFilename(hintName)) ? sanitizeFilename(hintName) : filenameFromUrl(link); + const item: DownloadItem = { + id: itemId, packageId, url: link, provider: null, status: "queued", + retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0, + fileName, targetPath: "", resumable: true, attempts: 0, + lastError: "", fullStatus: "Wartet", createdAt: nowMs(), updatedAt: nowMs(), + }; + const targetPath = path.join(outputDir, fileName); + item.targetPath = this.claimTargetPath(itemId, targetPath); + packageEntry.itemIds.push(itemId); + this.session.items[itemId] = item; + this.itemCount++; + if (this.session.running) { + this.runItemIds.add(itemId); + this.runPackageIds.add(packageId); + } + addedLinks++; + } + this.session.packages[packageId] = packageEntry; + this.session.packageOrder.push(packageId); + addedPackages++; + } + if (addedPackages > 0) { + logger.info(`Pakete hinzugefügt: ${addedPackages} Paket(e), ${addedLinks} Link(s)`); + } + this.persistSoon(); + this.emitState(); + return { addedPackages, addedLinks }; + } + + public clearAll(): void { + this.clearPersistTimer(); + this.stop(); + this.postProcessor.abortAll(); + if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; } + this.session.packageOrder = []; + this.session.packages = {}; + this.session.items = {}; + this.itemCount = 0; + this.session.summaryText = ""; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.historyRecordedPackages.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.itemContributedBytes.clear(); + this.cachedDirectUrls.clear(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.summary = null; + this.persistNow(); + this.emitState(true); + } + + public exportQueue(): string { + const exportData = { + version: 1, + packages: this.session.packageOrder.map(id => { + const pkg = this.session.packages[id]; + if (!pkg) return null; + return { name: pkg.name, links: pkg.itemIds.map(iid => this.session.items[iid]?.url).filter(Boolean) }; + }).filter(Boolean), + }; + return JSON.stringify(exportData, null, 2); + } + + public importQueue(json: string): { addedPackages: number; addedLinks: number } { + let data: any; + try { data = JSON.parse(json); } catch { throw new Error("Ungültige Queue-Datei (JSON)"); } + if (!Array.isArray(data.packages)) return { addedPackages: 0, addedLinks: 0 }; + const inputs: ParsedPackageInput[] = data.packages + .map((pkg: any) => ({ + name: typeof pkg?.name === "string" ? pkg.name : "", + links: (Array.isArray(pkg?.links) ? pkg.links : []).filter((l: any) => typeof l === "string").map((l: string) => l.trim()).filter(Boolean), + })) + .filter((pkg: ParsedPackageInput) => pkg.name.trim().length > 0 && pkg.links.length > 0); + return this.addPackages(inputs); + } + + // ----------------------------------------------------------------------- + // Package Operations + // ----------------------------------------------------------------------- + + public renamePackage(packageId: string, newName: string): void { + const pkg = this.session.packages[packageId]; + if (pkg) { pkg.name = sanitizeFilename(newName); pkg.updatedAt = nowMs(); this.persistSoon(); this.emitState(); } + } + + public reorderPackages(packageIds: string[]): void { + const valid = packageIds.filter(id => this.session.packages[id]); + const existing = new Set(valid); + const remaining = this.session.packageOrder.filter(id => !existing.has(id)); + this.session.packageOrder = [...valid, ...remaining]; + this.persistSoon(); + this.emitState(); + } + + public togglePackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + pkg.enabled = !pkg.enabled; + pkg.updatedAt = nowMs(); + if (!pkg.enabled && this.session.running) { + // Abort active downloads for this package + for (const slot of this.activeTasks.values()) { + if (slot.packageId === packageId) { + slot.abortReason = "package_toggle"; + slot.abortController.abort("package_toggle"); + } + } + } + this.persistSoon(); + this.emitState(); + } + + public cancelPackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + pkg.cancelled = true; + pkg.status = "cancelled"; + pkg.updatedAt = nowMs(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const slot = this.activeTasks.get(itemId); + if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); } + if (!isFinishedStatus(item.status)) { + item.status = "cancelled"; + item.fullStatus = "Abgebrochen"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + this.releaseTargetPath(itemId); + this.retryManager.removeItem(itemId); + } + this.postProcessor.abortPackage(packageId); + this.persistSoon(); + this.emitState(); + } + + public resetPackage(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + pkg.cancelled = false; + pkg.status = "queued"; + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const slot = this.activeTasks.get(itemId); + if (slot) { slot.abortReason = "reset"; slot.abortController.abort("reset"); } + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.retries = 0; + item.attempts = 0; + item.updatedAt = nowMs(); + this.retryManager.resetItem(itemId); + this.cachedDirectUrls.delete(itemId); + } + this.persistSoon(); + this.emitState(); + } + + public setPackagePriority(packageId: string, priority: PackagePriority): void { + const pkg = this.session.packages[packageId]; + if (pkg) { pkg.priority = priority; pkg.updatedAt = nowMs(); this.persistSoon(); this.emitState(); } + } + + // ----------------------------------------------------------------------- + // Item Operations + // ----------------------------------------------------------------------- + + public removeItem(itemId: string): void { + const item = this.session.items[itemId]; + if (!item) return; + const slot = this.activeTasks.get(itemId); + if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); } + const pkg = this.session.packages[item.packageId]; + if (pkg) { pkg.itemIds = pkg.itemIds.filter(id => id !== itemId); pkg.updatedAt = nowMs(); } + this.releaseTargetPath(itemId); + this.retryManager.removeItem(itemId); + delete this.session.items[itemId]; + this.itemCount--; + this.persistSoon(); + this.emitState(); + } + + public skipItems(itemIds: string[]): void { + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item || isFinishedStatus(item.status)) continue; + const slot = this.activeTasks.get(itemId); + if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); } + item.status = "cancelled"; + item.fullStatus = "Übersprungen"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + this.persistSoon(); + this.emitState(); + } + + public resetItems(itemIds: string[]): void { + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const slot = this.activeTasks.get(itemId); + if (slot) { slot.abortReason = "reset"; slot.abortController.abort("reset"); } + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.retries = 0; + item.attempts = 0; + item.provider = null; + item.updatedAt = nowMs(); + this.retryManager.resetItem(itemId); + this.cachedDirectUrls.delete(itemId); + } + this.persistSoon(); + this.emitState(); + } + + // ----------------------------------------------------------------------- + // Start / Stop / Pause + // ----------------------------------------------------------------------- + + public async start(): Promise { + if (this.session.running) return; + + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.sessionDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.consecutiveReconnects = 0; + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.summary = null; + this.itemContributedBytes.clear(); + this.cachedDirectUrls.clear(); + + // Recover stopped items + for (const item of Object.values(this.session.items)) { + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + const pkg = this.session.packages[item.packageId]; + if (pkg && !pkg.cancelled && pkg.enabled) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + } + } + + // Identify items to run + const runItems = Object.values(this.session.items).filter(item => { + if (item.status !== "queued" && item.status !== "reconnect_wait") return false; + const pkg = this.session.packages[item.packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled); + }); + + if (runItems.length === 0) { + this.session.running = false; + this.session.runStartedAt = 0; + this.persistSoon(); + this.emitState(true); + return; + } + + this.runItemIds = new Set(runItems.map(i => i.id)); + this.runPackageIds = new Set(runItems.map(i => i.packageId)); + this.runOutcomes.clear(); + + this.persistSoon(); + this.emitState(true); + + // Start scheduler + void this.scheduler.start( + this.session, + (slot) => this.startItem(slot), + ).catch(error => { + logger.error(`Scheduler crashed: ${compactErrorText(error)}`); + this.session.running = false; + this.persistSoon(); + this.emitState(true); + }); + } + + public async startPackages(packageIds: string[]): Promise { + if (this.session.running) return; + // Set only specified packages' items to queued + for (const packageId of packageIds) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) continue; + pkg.enabled = true; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (item && !isFinishedStatus(item.status)) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + } + } + await this.start(); + } + + public async startItems(itemIds: string[]): Promise { + if (this.session.running) return; + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (item && !isFinishedStatus(item.status)) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + } + await this.start(); + } + + public stop(): void { + const keepExtraction = this.settings.autoExtractWhenStopped; + this.scheduler.stop(); + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + + if (!keepExtraction) { + this.postProcessor.abortAll(); + } + + // Abort all active downloads + this.scheduler.abortAll("stop"); + for (const slot of this.activeTasks.values()) { + slot.abortReason = "stop"; + slot.abortController.abort("stop"); + } + + // Reset non-finished items + for (const item of Object.values(this.session.items)) { + if (!isFinishedStatus(item.status)) { + item.status = "queued"; + item.speedBps = 0; + const pkg = this.session.packages[item.packageId]; + item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; + item.updatedAt = nowMs(); + } + } + for (const pkg of Object.values(this.session.packages)) { + if (!keepExtraction || (pkg.status !== "extracting" && pkg.status !== "integrity_check")) { + if (["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"].includes(pkg.status)) { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + } + } + } + + this.persistSoon(); + this.emitState(true); + } + + public togglePause(): boolean { + if (!this.session.running) return false; + const wasPaused = this.session.paused; + this.session.paused = !this.session.paused; + this.scheduler.setPaused(this.session.paused); + + if (!wasPaused && this.session.paused) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + } + + if (wasPaused && !this.session.paused) { + // Clear delays so items restart immediately + for (const item of Object.values(this.session.items)) { + if (item.status === "queued" || item.status === "reconnect_wait") { + this.scheduler.clearRetryDelay(item.id); + } + } + } + + this.persistSoon(); + this.emitState(true); + return this.session.paused; + } + + // ----------------------------------------------------------------------- + // Conflict resolution + // ----------------------------------------------------------------------- + + public async getStartConflicts(): Promise { + const conflicts: StartConflictEntry[] = []; + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (pkg.extractDir) { + try { + const stat = await fs.promises.stat(pkg.extractDir); + if (stat.isDirectory()) { + conflicts.push({ packageId, packageName: pkg.name, extractDir: pkg.extractDir }); + } + } catch { /* doesn't exist */ } + } + } + return conflicts; + } + + public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise { + if (policy === "skip") { + const pkg = this.session.packages[packageId]; + if (pkg) { pkg.enabled = false; pkg.updatedAt = nowMs(); } + return { skipped: true, overwritten: false }; + } + if (policy === "overwrite") { + const pkg = this.session.packages[packageId]; + if (pkg?.extractDir) { + try { await fs.promises.rm(pkg.extractDir, { recursive: true, force: true }); } catch {} + } + return { skipped: false, overwritten: true }; + } + return { skipped: false, overwritten: false }; + } + + // ----------------------------------------------------------------------- + // Extraction control + // ----------------------------------------------------------------------- + + public retryExtraction(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + this.postProcessor.retryPackage(packageId, this.buildPostProcessOptions(pkg)); + } + + public extractNow(packageId: string): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + this.postProcessor.queuePackage(packageId, this.buildPostProcessOptions(pkg)); + } + + public triggerIdleExtractions(): void { + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + const items = pkg.itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const allDone = items.length > 0 && items.every(i => isFinishedStatus(i.status)); + const anySuccess = items.some(i => i.status === "completed"); + if (allDone && anySuccess && this.settings.autoExtract) { + this.postProcessor.queuePackage(packageId, this.buildPostProcessOptions(pkg)); + } + } + } + + public abortAllPostProcessing(): void { + this.postProcessor.abortAll(); + } + + // ----------------------------------------------------------------------- + // Stats + // ----------------------------------------------------------------------- + + public resetSessionStats(): void { + this.sessionDownloadedBytes = 0; + this.sessionCompletedFiles = 0; + this.session.totalDownloadedBytes = 0; + this.emitState(); + } + + public resetDownloadStats(): void { + this.settings.totalDownloadedAllTime = 0; + this.settings.totalCompletedFilesAllTime = 0; + this.persistSoon(); + this.emitState(); + } + + // ----------------------------------------------------------------------- + // Shutdown + // ----------------------------------------------------------------------- + + public prepareForShutdown(): void { + logger.info(`Shutdown: active=${this.activeTasks.size}, running=${this.session.running}`); + this.clearPersistTimer(); + if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; } + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.scheduler.stop(); + this.postProcessor.abortAll(); + + for (const slot of this.activeTasks.values()) { + const item = this.session.items[slot.itemId]; + if (item && !isFinishedStatus(item.status)) { + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); + } + slot.abortReason = "shutdown"; + slot.abortController.abort("shutdown"); + } + + for (const pkg of Object.values(this.session.packages)) { + if (["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"].includes(pkg.status)) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + } + + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + + if (!this.skipShutdownPersist && !this.blockAllPersistence) { + saveSession(this.storagePaths, this.session); + saveSettings(this.storagePaths, this.settings); + } + this.emitState(true); + logger.info("Shutdown complete"); + } + + public clearPersistTimer(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + } + + // ----------------------------------------------------------------------- + // Private: Item processing (using Pipeline) + // ----------------------------------------------------------------------- + + private startItem(slot: SlotRequest): void { + const item = this.session.items[slot.itemId]; + const pkg = this.session.packages[slot.packageId]; + if (!item || !pkg || pkg.cancelled || !pkg.enabled) return; + if (item.status !== "queued" && item.status !== "reconnect_wait") return; + if (this.activeTasks.has(slot.itemId)) return; + + const ac = new AbortController(); + const activeSlot = this.scheduler.claimSlot(slot.itemId, slot.packageId, ac); + this.activeTasks.set(slot.itemId, activeSlot); + + item.status = "validating"; + item.fullStatus = "Link wird umgewandelt"; + item.speedBps = 0; + if (item.downloadedBytes === 0) item.progressPercent = 0; + item.updatedAt = nowMs(); + pkg.status = "downloading"; + pkg.updatedAt = nowMs(); + this.emitState(); + + void this.processItem(activeSlot).catch(err => { + logger.warn(`processItem error (${slot.itemId}): ${compactErrorText(err)}`); + }).finally(() => { + this.activeTasks.delete(slot.itemId); + this.scheduler.releaseSlot(slot.itemId); + this.persistSoon(); + this.emitState(); + }); + } + + private async processItem(slot: ActiveSlot): Promise { + const item = this.session.items[slot.itemId]; + const pkg = this.session.packages[slot.packageId]; + if (!item || !pkg) return; + + // Check for cached direct URL (from previous failed attempt) + const cached = this.cachedDirectUrls.get(slot.itemId); + + const ctx: PipelineContext = { + item, + pkg, + settings: this.settings, + debridService: this.debridService as any, + integrityChecker: this.settings.enableIntegrityCheck ? { + validateFile: (filePath: string, packageDir: string) => validateFileAgainstManifest(filePath, packageDir), + } : undefined, + signal: slot.abortController.signal, + cachedDirectUrl: cached?.url, + cachedProvider: cached?.provider as any, + cachedProviderLabel: cached?.label, + cachedSkipTls: cached?.skipTls, + onStatus: (status, fullStatus) => { + item.status = status as DownloadStatus; + item.fullStatus = fullStatus; + item.updatedAt = nowMs(); + this.emitState(); + }, + onProgress: (downloadedBytes, totalBytes, speedBps) => { + const delta = downloadedBytes - item.downloadedBytes; + if (delta > 0) { + this.session.totalDownloadedBytes += delta; + this.sessionDownloadedBytes += delta; + this.settings.totalDownloadedAllTime += delta; + this.itemContributedBytes.set(slot.itemId, (this.itemContributedBytes.get(slot.itemId) || 0) + delta); + this.recordSpeed(delta, slot.packageId); + } + item.downloadedBytes = downloadedBytes; + item.totalBytes = totalBytes; + item.speedBps = speedBps; + item.progressPercent = totalBytes ? Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))) : 0; + item.updatedAt = nowMs(); + this.scheduler.heartbeat(slot.itemId, downloadedBytes); + this.emitState(); + }, + onResumable: (resumable) => { + item.resumable = resumable; + slot.resumable = resumable; + }, + onFileNameOverride: (newName, newTargetPath) => { + item.fileName = newName; + item.targetPath = newTargetPath; + item.updatedAt = nowMs(); + this.emitState(); + }, + onProviderInfo: (provider, label, accountId, accountLabel) => { + item.provider = provider; + item.providerLabel = label; + item.providerAccountId = accountId; + item.providerAccountLabel = accountLabel; + this.scheduler.clearProviderCooldown(provider); + }, + onHeartbeat: () => { + this.scheduler.heartbeat(slot.itemId, item.downloadedBytes); + }, + onDiskBusy: (busy) => { + slot.blockedOnDiskWrite = busy; + slot.blockedOnDiskSince = busy ? nowMs() : 0; + }, + onLog: (level, message, fields) => { + logger[level === "ERROR" ? "error" : level === "WARN" ? "warn" : "info"](`[${item.fileName || item.id}] ${message}`); + }, + claimTargetPath: (itemId, path, keep) => this.claimTargetPath(itemId, path, keep), + releaseTargetPath: (itemId) => this.releaseTargetPath(itemId), + }; + + try { + const result = await runPipeline(ctx); + + // Success! + item.status = "completed"; + item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(item.downloadedBytes)})`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.sessionCompletedFiles++; + this.settings.totalCompletedFilesAllTime++; + this.runOutcomes.set(slot.itemId, "completed"); + this.cachedDirectUrls.delete(slot.itemId); + this.retryManager.removeItem(slot.itemId); + + // Cache the direct URL in case another item from same package needs it + if (result.directUrl) { + this.cachedDirectUrls.set(slot.itemId, { + url: result.directUrl, + provider: result.provider, + label: result.providerLabel || "", + skipTls: result.skipTlsVerify || false, + }); + } + + logger.info(`Download complete: ${item.fileName} (${humanSize(item.downloadedBytes)})`); + + // Check if package is done → trigger post-processing + this.refreshPackageStatus(pkg); + if (this.areAllPackageItemsDone(pkg)) { + this.triggerPostProcessing(pkg.id); + } else if (this.settings.hybridExtract && this.settings.autoExtract) { + // Hybrid: trigger even if not all done + this.triggerPostProcessing(pkg.id); + } + + } catch (error) { + // Abort — no retry + if (slot.abortController.signal.aborted) { + const reason = slot.abortReason; + if (reason === "cancel") { + item.status = "cancelled"; + item.fullStatus = "Abgebrochen"; + } else if (reason === "stop" || reason === "shutdown") { + item.status = "queued"; + item.fullStatus = reason === "stop" ? "Gestoppt" : "Wartet"; + } else if (reason === "package_toggle") { + item.status = "queued"; + item.fullStatus = "Paket gestoppt"; + } else if (reason === "stall") { + // Stall: handle via retry manager below + this.handleDownloadError(slot, item, pkg, ensureDownloadError(error)); + return; + } else { + item.status = "queued"; + item.fullStatus = "Wartet"; + } + item.speedBps = 0; + item.updatedAt = nowMs(); + if (reason === "cancel") { + this.runOutcomes.set(slot.itemId, "cancelled"); + // Delete partial file + if (item.targetPath) { + try { await fs.promises.rm(item.targetPath, { force: true }); } catch {} + } + } + this.persistSoon(); + this.emitState(); + return; + } + + // Download error → evaluate with RetryManager + const dlError = ensureDownloadError(error); + this.handleDownloadError(slot, item, pkg, dlError); + } + } + + private handleDownloadError(slot: ActiveSlot, item: DownloadItem, pkg: PackageEntry, error: DownloadError): void { + const decision = this.retryManager.evaluate(slot.itemId, error); + + item.lastError = error.message; + item.speedBps = 0; + item.updatedAt = nowMs(); + + logger.warn(`Download error [${error.kind}]: ${item.fileName} — ${decision.reason}`); + + if (!decision.shouldRetry) { + // Failed permanently + item.status = "failed"; + item.fullStatus = decision.reason; + this.runOutcomes.set(slot.itemId, "failed"); + this.refreshPackageStatus(pkg); + this.persistSoon(); + this.emitState(); + return; + } + + // Execute retry actions + for (const action of decision.actions) { + this.executeRetryAction(action, slot, item, error); + } + + // Queue for retry + item.status = "queued"; + item.fullStatus = decision.reason; + item.retries++; + this.scheduler.scheduleRetry(slot.itemId, decision.delayMs); + this.persistSoon(); + this.emitState(); + } + + private executeRetryAction(action: RetryAction, slot: ActiveSlot, item: DownloadItem, error: DownloadError): void { + switch (action) { + case "reset_file": + if (item.targetPath) { + try { fs.rmSync(item.targetPath, { force: true }); } catch {} + } + this.dropItemContribution(slot.itemId); + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + break; + + case "switch_provider": + item.provider = null; + this.cachedDirectUrls.delete(slot.itemId); + break; + + case "refresh_link": + this.cachedDirectUrls.delete(slot.itemId); + break; + + case "cooldown_provider": + if (item.provider) { + const policy = RETRY_POLICIES[error.kind]; + if (policy?.providerCooldownMs > 0) { + this.scheduler.applyProviderCooldown(item.provider, policy.providerCooldownMs); + } + } + break; + + case "shelve": + // Shelving already handled by RetryManager (90s delay) + item.provider = null; + this.cachedDirectUrls.delete(slot.itemId); + break; + } + } + + // ----------------------------------------------------------------------- + // Private: Event handlers + // ----------------------------------------------------------------------- + + private handleStallDetected(itemId: string): void { + const slot = this.activeTasks.get(itemId); + if (!slot) return; + slot.abortReason = "stall"; + slot.abortController.abort("stall"); + } + + private handleGlobalStall(itemIds: string[]): void { + logger.warn(`Global stall detected: ${itemIds.length} downloads stalled`); + for (const itemId of itemIds) { + this.handleStallDetected(itemId); + } + } + + private handleExtractProgress(packageId: string, update: ExtractProgressUpdate): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + pkg.status = "extracting"; + pkg.postProcessLabel = `Entpacken ${update.percent}%`; + pkg.updatedAt = nowMs(); + // Update items + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (item && item.status === "completed") { + item.fullStatus = `Entpacken ${update.percent}%`; + item.updatedAt = nowMs(); + } + } + this.emitState(); + } + + private handlePostProcessDone(packageId: string, success: boolean, errors: string[]): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + + if (success) { + pkg.status = "completed"; + pkg.postProcessLabel = undefined; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (item && item.status === "completed") { + item.fullStatus = `Entpackt`; + item.updatedAt = nowMs(); + } + } + // Cleanup after extraction + if (this.settings.cleanupMode !== "none") { + void this.cleanupAfterExtraction(pkg).catch(e => + logger.warn(`Cleanup error: ${compactErrorText(e)}`)); + } + } else { + pkg.status = "failed"; + pkg.postProcessLabel = errors.length > 0 ? errors[0] : "Entpacken fehlgeschlagen"; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (item && item.status === "completed") { + item.fullStatus = `Entpacken fehlgeschlagen`; + item.updatedAt = nowMs(); + } + } + } + pkg.updatedAt = nowMs(); + + // Record history + this.recordHistory(pkg); + + this.persistSoon(); + this.emitState(); + } + + private handleArchiveRedownload(packageId: string, archiveName: string, error: string): void { + logger.warn(`Archive redownload requested: ${archiveName} in package ${packageId}`); + // Find items that match this archive and re-queue them + const pkg = this.session.packages[packageId]; + if (!pkg) return; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.targetPath && path.basename(item.targetPath) === archiveName) { + // Delete corrupt file + try { fs.rmSync(item.targetPath, { force: true }); } catch {} + item.status = "queued"; + item.fullStatus = "Erneuter Download (Archiv beschädigt)"; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.updatedAt = nowMs(); + this.retryManager.resetItem(itemId); + this.cachedDirectUrls.delete(itemId); + } + } + this.persistSoon(); + this.emitState(); + } + + // ----------------------------------------------------------------------- + // Private: Post-processing + // ----------------------------------------------------------------------- + + private triggerPostProcessing(packageId: string): void { + if (!this.settings.autoExtract) return; + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return; + this.postProcessor.queuePackage(packageId, this.buildPostProcessOptions(pkg)); + } + + private buildPostProcessOptions(pkg: PackageEntry): PostProcessOptions { + return { + packageDir: pkg.outputDir, + extractDir: pkg.extractDir, + cleanupMode: this.settings.cleanupMode, + conflictMode: this.settings.extractConflictMode, + removeLinks: this.settings.removeLinkFilesAfterExtract, + removeSamples: this.settings.removeSamplesAfterExtract, + passwordList: this.settings.archivePasswordList, + hybridMode: this.settings.hybridExtract, + maxParallelExtract: this.settings.maxParallelExtract, + extractCpuPriority: this.settings.extractCpuPriority, + signal: new AbortController().signal, // PostProcessor manages its own abort + }; + } + + private async cleanupAfterExtraction(pkg: PackageEntry): Promise { + if (this.settings.removeLinkFilesAfterExtract) { + await removeDownloadLinkArtifacts(pkg.extractDir); + } + if (this.settings.removeSamplesAfterExtract) { + await removeSampleArtifacts(pkg.extractDir); + } + } + + // ----------------------------------------------------------------------- + // Private: Speed tracking + // ----------------------------------------------------------------------- + + private recordSpeed(bytes: number, packageId: string = ""): void { + const now = nowMs(); + if (bytes > 0 && this.consecutiveReconnects > 0) this.consecutiveReconnects = 0; + const bucket = now - (now % 120); + const last = this.speedEvents[this.speedEvents.length - 1]; + if (last && last.at === bucket && last.pid === packageId) { + last.bytes += bytes; + } else { + this.speedEvents.push({ at: bucket, bytes, pid: packageId }); + } + this.speedBytesLastWindow += bytes; + this.speedBytesPerPackage.set(packageId, (this.speedBytesPerPackage.get(packageId) ?? 0) + bytes); + if (now - this.lastSpeedPruneAt >= 1500) { + this.pruneSpeedEvents(now); + this.lastSpeedPruneAt = now; + } + } + + private pruneSpeedEvents(now: number): void { + const cutoff = now - SPEED_WINDOW_SECONDS * 1000; + let newHead = this.speedEventsHead; + while (newHead < this.speedEvents.length && this.speedEvents[newHead].at < cutoff) newHead++; + if (newHead > this.speedEventsHead) { + this.speedEventsHead = newHead; + // Recalculate window totals + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + for (let i = newHead; i < this.speedEvents.length; i++) { + const e = this.speedEvents[i]; + this.speedBytesLastWindow += e.bytes; + this.speedBytesPerPackage.set(e.pid, (this.speedBytesPerPackage.get(e.pid) ?? 0) + e.bytes); + } + } + // Compact if head is far from start + if (this.speedEventsHead > 500) { + this.speedEvents = this.speedEvents.slice(this.speedEventsHead); + this.speedEventsHead = 0; + } + } + + // ----------------------------------------------------------------------- + // Private: Target path management + // ----------------------------------------------------------------------- + + private claimTargetPath(itemId: string, preferredPath: string, allowExisting = false): string { + const preferredKey = pathKey(preferredPath); + const existingClaim = this.claimedTargetPathByItem.get(itemId); + if (existingClaim) { + const existingKey = pathKey(existingClaim); + const owner = this.reservedTargetPaths.get(existingKey); + if (owner === itemId) { + if (existingKey === preferredKey) return existingClaim; + this.reservedTargetPaths.delete(existingKey); + } + this.claimedTargetPathByItem.delete(itemId); + } + const parsed = path.parse(preferredPath); + for (let i = 0; i <= 10000; i++) { + const candidate = i === 0 ? preferredPath : path.join(parsed.dir, `${parsed.name} (${i})${parsed.ext}`); + const key = pathKey(candidate); + const owner = this.reservedTargetPaths.get(key); + const exists = fs.existsSync(candidate); + const allowCandidate = allowExisting && i === 0; + if ((!owner || owner === itemId) && (owner === itemId || !exists || allowCandidate)) { + this.reservedTargetPaths.set(key, itemId); + this.claimedTargetPathByItem.set(itemId, candidate); + return candidate; + } + } + const fallback = path.join(parsed.dir, `${parsed.name} (${Date.now()})${parsed.ext}`); + this.reservedTargetPaths.set(pathKey(fallback), itemId); + this.claimedTargetPathByItem.set(itemId, fallback); + return fallback; + } + + private releaseTargetPath(itemId: string): void { + const claimed = this.claimedTargetPathByItem.get(itemId); + if (!claimed) return; + const key = pathKey(claimed); + if (this.reservedTargetPaths.get(key) === itemId) this.reservedTargetPaths.delete(key); + this.claimedTargetPathByItem.delete(itemId); + } + + // ----------------------------------------------------------------------- + // Private: State management + // ----------------------------------------------------------------------- + + private emitState(force = false): void { + const now = nowMs(); + const MIN_GAP = 120; + if (force) { + const since = now - this.lastStateEmitAt; + if (since >= MIN_GAP) { + if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; } + this.lastStateEmitAt = now; + this.emit("state", this.getSnapshot()); + return; + } + if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); this.stateEmitTimer = null; } + this.stateEmitTimer = setTimeout(() => { + this.stateEmitTimer = null; + this.lastStateEmitAt = nowMs(); + this.emit("state", this.getSnapshot()); + }, MIN_GAP - since); + return; + } + if (this.stateEmitTimer) return; + const delay = this.session.running + ? this.itemCount >= 1500 ? 700 : this.itemCount >= 700 ? 500 : this.itemCount >= 250 ? 300 : 150 + : 200; + this.stateEmitTimer = setTimeout(() => { + this.stateEmitTimer = null; + this.lastStateEmitAt = nowMs(); + this.emit("state", this.getSnapshot()); + }, delay); + } + + private persistSoon(): void { + if (this.persistTimer || this.blockAllPersistence) return; + const minGap = this.session.running + ? this.itemCount >= 1500 ? 3000 : this.itemCount >= 700 ? 2200 : this.itemCount >= 250 ? 1500 : 700 + : 300; + const since = nowMs() - this.lastPersistAt; + const delay = Math.max(120, minGap - since); + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.persistNow(); + }, delay); + } + + private persistNow(): void { + if (this.blockAllPersistence) return; + this.lastPersistAt = nowMs(); + try { + saveSession(this.storagePaths, this.session); + saveSettings(this.storagePaths, this.settings); + } catch (err) { + logger.error(`Persist error: ${compactErrorText(err)}`); + } + } + + // ----------------------------------------------------------------------- + // Private: helpers + // ----------------------------------------------------------------------- + + private dropItemContribution(itemId: string): void { + const contributed = this.itemContributedBytes.get(itemId) || 0; + if (contributed > 0) { + this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed); + this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - contributed); + this.itemContributedBytes.set(itemId, 0); + } + } + + private refreshPackageStatus(pkg: PackageEntry): void { + const items = pkg.itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + if (items.length === 0) return; + const allDone = items.every(i => isFinishedStatus(i.status)); + if (!allDone) return; + const anyFailed = items.some(i => i.status === "failed"); + const anySuccess = items.some(i => i.status === "completed"); + if (anyFailed) { pkg.status = "failed"; } + else if (anySuccess) { pkg.status = "completed"; } + else { pkg.status = "cancelled"; } + pkg.updatedAt = nowMs(); + } + + private areAllPackageItemsDone(pkg: PackageEntry): boolean { + return pkg.itemIds.every(id => { + const item = this.session.items[id]; + return item && isFinishedStatus(item.status); + }); + } + + private finishRun(): void { + if (!this.session.running) return; + const items = Object.values(this.session.items); + const runItems = this.runItemIds.size > 0 + ? items.filter(i => this.runItemIds.has(i.id)) + : items; + const success = runItems.filter(i => i.status === "completed").length; + const failed = runItems.filter(i => i.status === "failed").length; + const cancelled = runItems.filter(i => i.status === "cancelled").length; + const elapsed = this.session.runStartedAt > 0 ? (nowMs() - this.session.runStartedAt) / 1000 : 0; + this.summary = { + total: runItems.length, + success, + failed, + cancelled, + extracted: 0, + durationSeconds: Math.floor(elapsed), + averageSpeedBps: elapsed > 0 ? Math.floor(this.session.totalDownloadedBytes / elapsed) : 0, + }; + this.session.running = false; + this.session.paused = false; + this.session.summaryText = `${success} fertig, ${failed} fehlgeschlagen, ${cancelled} abgebrochen`; + logger.info(`Run complete: ${this.session.summaryText} in ${formatEta(elapsed)}`); + + // Record history for all completed packages + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (pkg && !this.historyRecordedPackages.has(packageId)) { + this.recordHistory(pkg); + } + } + + this.persistSoon(); + this.emitState(true); + } + + private recordHistory(pkg: PackageEntry): void { + if (this.historyRecordedPackages.has(pkg.id)) return; + const items = pkg.itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const success = items.filter(i => i.status === "completed").length; + if (success === 0) return; + this.historyRecordedPackages.add(pkg.id); + const totalBytes = items.reduce((sum, i) => sum + (i.downloadedBytes || 0), 0); + const entry: HistoryEntry = { + id: uuidv4(), + name: pkg.name, + totalBytes, + downloadedBytes: totalBytes, + fileCount: success, + provider: items.find(i => i.provider)?.provider || null, + completedAt: nowMs(), + durationSeconds: this.session.runStartedAt > 0 ? Math.floor((nowMs() - this.session.runStartedAt) / 1000) : 0, + status: "completed", + outputDir: pkg.outputDir, + }; + this.onHistoryEntryCallback?.(entry); + } + + private restoreTargetPathReservations(): void { + for (const item of Object.values(this.session.items)) { + if (item.targetPath) { + const key = pathKey(item.targetPath); + this.reservedTargetPaths.set(key, item.id); + this.claimedTargetPathByItem.set(item.id, item.targetPath); + } + } + } + + private normalizeSessionStatuses(): void { + for (const item of Object.values(this.session.items)) { + if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + } + for (const pkg of Object.values(this.session.packages)) { + if (pkg.status === "downloading" || pkg.status === "validating") { + pkg.status = "queued"; + pkg.updatedAt = nowMs(); + } + } + } +} diff --git a/src/main/download/error-classifier.ts b/src/main/download/error-classifier.ts new file mode 100644 index 0000000..f5e1a23 --- /dev/null +++ b/src/main/download/error-classifier.ts @@ -0,0 +1,508 @@ +/** + * error-classifier.ts — Typed error system for download pipeline. + * + * Every error gets classified ONCE at the point of origin into a + * DownloadErrorKind. No post-hoc string matching needed downstream. + */ + +// --------------------------------------------------------------------------- +// Error Kinds +// --------------------------------------------------------------------------- + +export enum DownloadErrorKind { + // Network + NetworkReset = "network_reset", + Timeout = "timeout", + DnsFailure = "dns_failure", + ConnectTimeout = "connect_timeout", + + // HTTP + RangeNotSatisfied = "range_not_satisfied", + RangeIgnored = "range_ignored", + ServerError = "server_error", + RateLimited = "rate_limited", + Forbidden = "forbidden", + NotFound = "not_found", + + // Provider / Debrid + UnrestrictFailed = "unrestrict_failed", + ProviderBusy = "provider_busy", + ProviderDown = "provider_down", + HosterUnavailable = "hoster_unavailable", + LinkDead = "link_dead", + QuotaExceeded = "quota_exceeded", + + // Filesystem + DiskFull = "disk_full", + PermissionDenied = "permission_denied", + FileLocked = "file_locked", + + // Integrity / Resume + FileCorrupt = "file_corrupt", + FileTruncated = "file_truncated", + ResumeUnderflow = "resume_underflow", + + // Extraction + WrongPassword = "wrong_password", + ArchiveCorrupt = "archive_corrupt", + ExtractorCrash = "extractor_crash", + + // Write / Drain + WriteDrainTimeout = "write_drain_timeout", + + // Catchall + Unknown = "unknown", +} + +// --------------------------------------------------------------------------- +// Permanent kinds — retrying is pointless +// --------------------------------------------------------------------------- + +const PERMANENT_KINDS = new Set([ + DownloadErrorKind.LinkDead, + DownloadErrorKind.DiskFull, + DownloadErrorKind.PermissionDenied, + DownloadErrorKind.WrongPassword, +]); + +export function isPermanentKind(kind: DownloadErrorKind): boolean { + return PERMANENT_KINDS.has(kind); +} + +// --------------------------------------------------------------------------- +// DownloadError class +// --------------------------------------------------------------------------- + +export class DownloadError extends Error { + readonly kind: DownloadErrorKind; + readonly retryable: boolean; + readonly permanent: boolean; + readonly httpStatus?: number; + readonly originalError?: Error; + /** Extra context (e.g. existing bytes, expected total). */ + readonly context?: Record; + + constructor( + kind: DownloadErrorKind, + message: string, + opts?: { + httpStatus?: number; + originalError?: Error; + retryable?: boolean; + permanent?: boolean; + context?: Record; + }, + ) { + super(message); + this.name = "DownloadError"; + this.kind = kind; + this.retryable = opts?.retryable ?? !isPermanentKind(kind); + this.permanent = opts?.permanent ?? isPermanentKind(kind); + this.httpStatus = opts?.httpStatus; + this.originalError = opts?.originalError; + this.context = opts?.context; + } + + /** Compact single-line representation for logging. */ + toLogString(): string { + const parts = [`[${this.kind}]`, this.message]; + if (this.httpStatus) parts.push(`(HTTP ${this.httpStatus})`); + return parts.join(" "); + } +} + +// --------------------------------------------------------------------------- +// Classifier: raw fetch / network errors +// --------------------------------------------------------------------------- + +export function classifyFetchError(error: unknown): DownloadError { + const text = errorText(error); + const lc = text.toLowerCase(); + + // Abort is not an error to classify — re-throw as-is + if (lc.includes("aborted:") || lc.includes("abort")) { + // Preserve abort errors unchanged so callers can check abortReason + throw error instanceof Error ? error : new Error(text); + } + + // Connection timeout + if (lc.includes("connect_timeout") || lc.includes("etimedout") || lc.includes("connection timed out")) { + return new DownloadError(DownloadErrorKind.ConnectTimeout, text, { + originalError: toError(error), + }); + } + + // DNS + if (lc.includes("enotfound") || lc.includes("getaddrinfo") || lc.includes("dns")) { + return new DownloadError(DownloadErrorKind.DnsFailure, text, { + originalError: toError(error), + }); + } + + // Network reset + if ( + lc.includes("fetch failed") || + lc.includes("socket hang up") || + lc.includes("econnreset") || + lc.includes("econnrefused") || + lc.includes("epipe") || + lc.includes("network error") || + lc.includes("econnaborted") || + lc.includes("socket closed") || + lc.includes("connection reset") + ) { + return new DownloadError(DownloadErrorKind.NetworkReset, text, { + originalError: toError(error), + }); + } + + // Stall / read timeout + if (lc.includes("stall_timeout") || lc.includes("read timeout")) { + return new DownloadError(DownloadErrorKind.Timeout, text, { + originalError: toError(error), + }); + } + + // Write drain timeout + if (lc.includes("write_drain_timeout")) { + return new DownloadError(DownloadErrorKind.WriteDrainTimeout, text, { + originalError: toError(error), + }); + } + + // Disk full + if (lc.includes("enospc") || lc.includes("no space left")) { + return new DownloadError(DownloadErrorKind.DiskFull, text, { + originalError: toError(error), + permanent: true, + }); + } + + // Permission denied + if (lc.includes("eacces") || lc.includes("eperm") || lc.includes("permission denied")) { + return new DownloadError(DownloadErrorKind.PermissionDenied, text, { + originalError: toError(error), + permanent: true, + }); + } + + // File locked (Windows) + if (lc.includes("ebusy") || lc.includes("file is locked") || lc.includes("being used by another process")) { + return new DownloadError(DownloadErrorKind.FileLocked, text, { + originalError: toError(error), + }); + } + + // Resume underflow + if (lc.startsWith("resume_download_underflow:")) { + return new DownloadError(DownloadErrorKind.ResumeUnderflow, text, { + originalError: toError(error), + }); + } + + // Range ignored on resume + if (lc.startsWith("range_ignored_on_resume:")) { + return new DownloadError(DownloadErrorKind.RangeIgnored, text, { + originalError: toError(error), + }); + } + + return new DownloadError(DownloadErrorKind.Unknown, text, { + originalError: toError(error), + }); +} + +// --------------------------------------------------------------------------- +// Classifier: HTTP response status codes +// --------------------------------------------------------------------------- + +export interface HttpClassifyContext { + status: number; + statusText?: string; + responseText?: string; + existingBytes?: number; + rangeHeaderSent?: boolean; +} + +export function classifyHttpStatus(ctx: HttpClassifyContext): DownloadError { + const { status, statusText, responseText } = ctx; + const body = responseText || statusText || ""; + const msg = `HTTP ${status}${body ? ": " + compactText(body) : ""}`; + + switch (true) { + case status === 416: + return new DownloadError(DownloadErrorKind.RangeNotSatisfied, msg, { + httpStatus: status, + context: { existingBytes: ctx.existingBytes }, + }); + + case status === 429: + return new DownloadError(DownloadErrorKind.RateLimited, msg, { + httpStatus: status, + }); + + case status === 403: + return new DownloadError(DownloadErrorKind.Forbidden, msg, { + httpStatus: status, + }); + + case status === 404: + return new DownloadError(DownloadErrorKind.NotFound, msg, { + httpStatus: status, + }); + + case status >= 500: + return new DownloadError(DownloadErrorKind.ServerError, msg, { + httpStatus: status, + }); + + default: + return new DownloadError(DownloadErrorKind.Unknown, msg, { + httpStatus: status, + }); + } +} + +/** + * Detect when the server ignored a Range header (sent 200 instead of 206). + * Call this AFTER receiving a 200 response when a Range header was sent. + */ +export function classifyRangeIgnored( + existingBytes: number, + contentLength: number, +): DownloadError { + return new DownloadError( + DownloadErrorKind.RangeIgnored, + `range_ignored_on_resume:${existingBytes}/${contentLength}`, + { context: { existingBytes, contentLength } }, + ); +} + +// --------------------------------------------------------------------------- +// Classifier: unrestrict / debrid API errors +// --------------------------------------------------------------------------- + +export function classifyUnrestrictError(error: unknown): DownloadError { + const text = errorText(error); + const lc = text.toLowerCase(); + + // Permanent: file is dead + if ( + lc.includes("permanent ungültig") || + /file.?not.?found/.test(lc) || + /file.?unavailable/.test(lc) || + /link.?is.?dead/.test(lc) || + lc.includes("file has been removed") || + lc.includes("file has been deleted") || + lc.includes("file is no longer available") || + lc.includes("file was removed") || + lc.includes("file was deleted") + ) { + return new DownloadError(DownloadErrorKind.LinkDead, text, { + originalError: toError(error), + permanent: true, + }); + } + + // Provider busy / concurrent limit + if ( + lc.includes("too many active") || + lc.includes("too many concurrent") || + lc.includes("too many downloads") || + lc.includes("active download") || + lc.includes("concurrent limit") || + lc.includes("slot limit") || + lc.includes("limit reached") || + lc.includes("zu viele aktive") || + lc.includes("zu viele gleichzeitige") || + lc.includes("zu viele downloads") + ) { + return new DownloadError(DownloadErrorKind.ProviderBusy, text, { + originalError: toError(error), + }); + } + + // Hoster unavailable + if (lc.includes("hosternotavailable")) { + return new DownloadError(DownloadErrorKind.HosterUnavailable, text, { + originalError: toError(error), + }); + } + + // Quota / traffic exceeded + if ( + lc.includes("quota") || + lc.includes("traffic") || + lc.includes("bandwidth limit") || + lc.includes("daily limit") + ) { + return new DownloadError(DownloadErrorKind.QuotaExceeded, text, { + originalError: toError(error), + }); + } + + // Provider temporarily down + if ( + lc.includes("server error") || + lc.includes("internal server error") || + lc.includes("temporarily unavailable") || + lc.includes("temporary unavailable") || + lc.includes("temporarily disabled") || + lc.includes("try again later") || + lc.includes("service unavailable") || + lc.includes("host is down") || + lc.includes("maintenance") || + lc.includes("bad gateway") || + lc.includes("gateway timeout") || + lc.includes("cloudflare") || + lc.includes("worker error") + ) { + return new DownloadError(DownloadErrorKind.ProviderDown, text, { + originalError: toError(error), + }); + } + + // Generic unrestrict failure (session, login, etc.) + if ( + lc.includes("unrestrict") || + lc.includes("mega-web") || + lc.includes("mega-debrid") || + lc.includes("bestdebrid") || + lc.includes("alldebrid") || + lc.includes("kein debrid") || + lc.includes("session-cookie") || + lc.includes("session cookie") || + lc.includes("session blockiert") || + lc.includes("session expired") || + lc.includes("invalid session") || + lc.includes("login ungültig") || + lc.includes("login liefert") || + lc.includes("login required") || + lc.includes("login failed") + ) { + return new DownloadError(DownloadErrorKind.UnrestrictFailed, text, { + originalError: toError(error), + }); + } + + return new DownloadError(DownloadErrorKind.Unknown, text, { + originalError: toError(error), + }); +} + +// --------------------------------------------------------------------------- +// Classifier: extraction errors +// --------------------------------------------------------------------------- + +export function classifyExtractionError( + errorText_: string, + category?: string, +): DownloadError { + const lc = (errorText_ || "").toLowerCase(); + + if (lc.includes("wrong password") || lc.includes("falsches passwort") || category === "wrong_password") { + return new DownloadError(DownloadErrorKind.WrongPassword, errorText_, { + permanent: true, + }); + } + + if ( + lc.includes("corrupt") || + lc.includes("unexpected end") || + lc.includes("broken header") || + lc.includes("invalid archive") || + lc.includes("bad signature") || + lc.includes("beschädigt") || + category === "archive_corrupt" + ) { + return new DownloadError(DownloadErrorKind.ArchiveCorrupt, errorText_); + } + + if ( + lc.includes("process exited") || + lc.includes("process crashed") || + lc.includes("extractor failed") || + lc.includes("segmentation fault") || + category === "extractor_crash" + ) { + return new DownloadError(DownloadErrorKind.ExtractorCrash, errorText_); + } + + if (lc.includes("enospc") || lc.includes("no space left")) { + return new DownloadError(DownloadErrorKind.DiskFull, errorText_, { + permanent: true, + }); + } + + return new DownloadError(DownloadErrorKind.Unknown, errorText_); +} + +// --------------------------------------------------------------------------- +// Convenience: wrap any unknown error into a DownloadError +// --------------------------------------------------------------------------- + +/** + * Ensure any thrown value becomes a DownloadError. + * If already a DownloadError, return as-is. + */ +export function ensureDownloadError(error: unknown): DownloadError { + if (error instanceof DownloadError) return error; + return classifyFetchError(error); +} + +// --------------------------------------------------------------------------- +// Human-readable error messages for UI +// --------------------------------------------------------------------------- + +const KIND_LABELS: Record = { + [DownloadErrorKind.NetworkReset]: "Netzwerkfehler", + [DownloadErrorKind.Timeout]: "Zeitüberschreitung", + [DownloadErrorKind.DnsFailure]: "DNS-Fehler", + [DownloadErrorKind.ConnectTimeout]: "Verbindungs-Timeout", + [DownloadErrorKind.RangeNotSatisfied]: "Range-Konflikt (HTTP 416)", + [DownloadErrorKind.RangeIgnored]: "Server ignorierte Resume", + [DownloadErrorKind.ServerError]: "Serverfehler", + [DownloadErrorKind.RateLimited]: "Rate-Limit erreicht", + [DownloadErrorKind.Forbidden]: "Zugriff verweigert", + [DownloadErrorKind.NotFound]: "Nicht gefunden", + [DownloadErrorKind.UnrestrictFailed]: "Unrestrict fehlgeschlagen", + [DownloadErrorKind.ProviderBusy]: "Provider ausgelastet", + [DownloadErrorKind.ProviderDown]: "Provider nicht erreichbar", + [DownloadErrorKind.HosterUnavailable]: "Hoster nicht verfügbar", + [DownloadErrorKind.LinkDead]: "Link ungültig / gelöscht", + [DownloadErrorKind.QuotaExceeded]: "Tages-Limit erreicht", + [DownloadErrorKind.DiskFull]: "Festplatte voll", + [DownloadErrorKind.PermissionDenied]: "Zugriff verweigert (Dateisystem)", + [DownloadErrorKind.FileLocked]: "Datei gesperrt", + [DownloadErrorKind.FileCorrupt]: "Datei beschädigt (CRC-Fehler)", + [DownloadErrorKind.FileTruncated]: "Download unvollständig", + [DownloadErrorKind.ResumeUnderflow]: "Resume-Fehler", + [DownloadErrorKind.WrongPassword]: "Falsches Archiv-Passwort", + [DownloadErrorKind.ArchiveCorrupt]: "Archiv beschädigt", + [DownloadErrorKind.ExtractorCrash]: "Entpacker abgestürzt", + [DownloadErrorKind.WriteDrainTimeout]: "Schreibvorgang blockiert", + [DownloadErrorKind.Unknown]: "Unbekannter Fehler", +}; + +export function errorKindLabel(kind: DownloadErrorKind): string { + return KIND_LABELS[kind] || KIND_LABELS[DownloadErrorKind.Unknown]; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function errorText(e: unknown): string { + if (typeof e === "string") return e; + if (e instanceof Error) return e.message || String(e); + return String(e ?? ""); +} + +function toError(e: unknown): Error { + if (e instanceof Error) return e; + return new Error(String(e ?? "")); +} + +function compactText(s: string): string { + return s.replace(/\s+/g, " ").trim().slice(0, 200); +} diff --git a/src/main/download/index.ts b/src/main/download/index.ts new file mode 100644 index 0000000..d7a5af6 --- /dev/null +++ b/src/main/download/index.ts @@ -0,0 +1,7 @@ +/** + * Download system v2 — public re-exports. + */ + +export { DownloadManager } from "./download-manager"; +export type { DownloadManagerOptions } from "./download-manager"; +export { DownloadError, DownloadErrorKind, errorKindLabel } from "./error-classifier"; diff --git a/src/main/download/pipeline.ts b/src/main/download/pipeline.ts new file mode 100644 index 0000000..f8f6c48 --- /dev/null +++ b/src/main/download/pipeline.ts @@ -0,0 +1,314 @@ +/** + * pipeline.ts — Single download lifecycle: unrestrict → stream → verify. + * + * The pipeline runs ONE download attempt. It does NOT handle retries — + * the caller (download-manager + retry-manager) decides what to do with errors. + * All errors thrown are typed DownloadErrors. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DownloadError, DownloadErrorKind, classifyUnrestrictError, classifyFetchError, ensureDownloadError } from "./error-classifier"; +import { streamToFile, type StreamResult } from "./stream-writer"; +import type { DownloadItem, PackageEntry, AppSettings, DebridProvider } from "../../shared/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Unrestricted link result from debrid service. */ +export interface UnrestrictedLink { + fileName: string; + directUrl: string; + fileSize: number | null; + retriesUsed: number; + skipTlsVerify?: boolean; + provider: DebridProvider; + providerLabel?: string; + sourceAccountId?: string; + sourceAccountLabel?: string; +} + +/** Debrid service interface — the pipeline only needs unrestrict. */ +export interface DebridUnrestrictor { + unrestrictLink(url: string, signal: AbortSignal): Promise; +} + +/** Integrity checker interface. */ +export interface IntegrityChecker { + validateFile(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }>; +} + +export interface PipelineContext { + item: DownloadItem; + pkg: PackageEntry; + settings: AppSettings; + debridService: DebridUnrestrictor; + integrityChecker?: IntegrityChecker; + signal: AbortSignal; + /** Reuse direct URL from previous attempt (skip unrestrict). */ + cachedDirectUrl?: string; + cachedProvider?: DebridProvider; + cachedProviderLabel?: string; + cachedSkipTls?: boolean; + + // Callbacks + onStatus: (status: string, fullStatus: string) => void; + onProgress: (downloadedBytes: number, totalBytes: number | null, speedBps: number) => void; + onResumable: (resumable: boolean) => void; + onFileNameOverride: (newName: string, newTargetPath: string) => void; + onProviderInfo: (provider: DebridProvider, label?: string, accountId?: string, accountLabel?: string) => void; + onHeartbeat: () => void; + onDiskBusy?: (busy: boolean) => void; + onLog: (level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record) => void; + + // Path management + claimTargetPath: (itemId: string, preferredPath: string, keepExisting?: boolean) => string; + releaseTargetPath: (itemId: string) => void; +} + +export interface PipelineResult { + success: boolean; + downloadedBytes: number; + totalBytes: number | null; + directUrl: string; + provider: DebridProvider; + providerLabel?: string; + resumable: boolean; + skipTlsVerify?: boolean; +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const DEFAULT_STALL_TIMEOUT_MS = 10_000; +const DEFAULT_CONNECT_TIMEOUT_MS = 25_000; +const DEFAULT_UNRESTRICT_TIMEOUT_MS = 60_000; +const DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS = 120_000; +const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024; + +function getEnvMs(name: string, defaultMs: number): number { + const val = process.env[name]; + if (!val) return defaultMs; + const n = Number(val); + return Number.isFinite(n) && n >= 0 ? n : defaultMs; +} + +const LARGE_BINARY_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; + +// --------------------------------------------------------------------------- +// Main pipeline function +// --------------------------------------------------------------------------- + +export async function runPipeline(ctx: PipelineContext): Promise { + const { item, pkg, settings, debridService, integrityChecker, signal } = ctx; + + // Abort guard + if (signal.aborted) throw new Error("aborted"); + + // ----- Step 1: Unrestrict ----- + let directUrl = ctx.cachedDirectUrl || ""; + let provider = ctx.cachedProvider || item.provider; + let providerLabel = ctx.cachedProviderLabel || ""; + let skipTlsVerify = ctx.cachedSkipTls || false; + + if (!directUrl) { + ctx.onStatus("validating", "Link wird umgewandelt..."); + ctx.onLog("INFO", "Unrestrict started", { url: item.url }); + + const unrestrictTimeoutMs = getEnvMs("RD_UNRESTRICT_TIMEOUT_MS", DEFAULT_UNRESTRICT_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs); + const combinedSignal = AbortSignal.any([signal, timeoutSignal]); + + let unrestricted: UnrestrictedLink; + try { + unrestricted = await debridService.unrestrictLink(item.url, combinedSignal); + } catch (error) { + if (signal.aborted) throw error; + if (timeoutSignal.aborted) { + throw new DownloadError( + DownloadErrorKind.ConnectTimeout, + `Unrestrict timeout after ${Math.ceil(unrestrictTimeoutMs / 1000)}s`, + ); + } + throw classifyUnrestrictError(error); + } + + if (signal.aborted) throw new Error("aborted"); + + directUrl = unrestricted.directUrl; + provider = unrestricted.provider; + providerLabel = unrestricted.providerLabel || ""; + skipTlsVerify = unrestricted.skipTlsVerify || false; + + // Update item metadata + ctx.onProviderInfo( + unrestricted.provider, + unrestricted.providerLabel, + unrestricted.sourceAccountId, + unrestricted.sourceAccountLabel, + ); + + // Resolve target path + const fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); + try { fs.mkdirSync(pkg.outputDir, { recursive: true }); } catch {} + + const existingPath = (item.targetPath || "").trim(); + const canReuse = existingPath + && isPathInsideDir(existingPath, pkg.outputDir) + && (item.downloadedBytes > 0 || fs.existsSync(existingPath)); + const preferred = canReuse ? existingPath : path.join(pkg.outputDir, fileName); + const targetPath = ctx.claimTargetPath(item.id, preferred, Boolean(canReuse)); + + // Update item fields + item.fileName = fileName; + item.targetPath = targetPath; + item.totalBytes = unrestricted.fileSize; + item.provider = unrestricted.provider; + item.providerLabel = unrestricted.providerLabel; + item.providerAccountId = unrestricted.sourceAccountId; + item.providerAccountLabel = unrestricted.sourceAccountLabel; + item.retries += unrestricted.retriesUsed; + + ctx.onLog("INFO", "Link unrestricted", { + provider: unrestricted.provider, + providerLabel: unrestricted.providerLabel || "", + fileName, + targetPath, + fileSize: unrestricted.fileSize, + directUrl, + }); + } + + // ----- Step 2: Stream download ----- + ctx.onStatus("downloading", `Download läuft (${providerLabel || providerDisplayName(provider)})`); + + const stallTimeoutMs = getEnvMs("RD_STALL_TIMEOUT_MS", DEFAULT_STALL_TIMEOUT_MS); + const connectTimeoutMs = getEnvMs("RD_CONNECT_TIMEOUT_MS", DEFAULT_CONNECT_TIMEOUT_MS); + const lowThroughputTimeoutMs = getEnvMs("RD_LOW_THROUGHPUT_TIMEOUT_MS", DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS); + const lowThroughputMinBytes = getEnvMs("RD_LOW_THROUGHPUT_MIN_BYTES", DEFAULT_LOW_THROUGHPUT_MIN_BYTES); + + // Speed limit + let effectiveSpeedLimit = 0; + if (settings.speedLimitEnabled && settings.speedLimitKbps > 0) { + effectiveSpeedLimit = settings.speedLimitKbps * 1024; + if (settings.speedLimitMode === "global") { + // For global mode, caller divides by active download count + // Here we just pass the per-download share + } + } + + let streamResult: StreamResult; + try { + streamResult = await streamToFile({ + url: directUrl, + targetPath: item.targetPath, + expectedBytes: item.totalBytes, + trackedDownloadedBytes: item.downloadedBytes, + stallTimeoutMs, + connectTimeoutMs, + skipTlsVerify, + speedLimitBps: effectiveSpeedLimit, + signal, + onProgress: ctx.onProgress, + onHeartbeat: ctx.onHeartbeat, + onResumable: ctx.onResumable, + onFileNameOverride: (newName) => { + const newPath = path.join(pkg.outputDir, newName); + ctx.releaseTargetPath(item.id); + const claimedPath = ctx.claimTargetPath(item.id, newPath); + item.fileName = newName; + item.targetPath = claimedPath; + ctx.onFileNameOverride(newName, claimedPath); + }, + onLog: ctx.onLog, + onDiskBusy: ctx.onDiskBusy, + lowThroughputTimeoutMs, + lowThroughputMinBytes, + isLargeBinary: LARGE_BINARY_RE.test(item.fileName || ""), + }); + } catch (error) { + if (signal.aborted) throw error; + throw ensureDownloadError(error); + } + + // Update item after successful download + item.downloadedBytes = streamResult.downloadedBytes; + item.totalBytes = streamResult.totalBytes; + + if (signal.aborted) throw new Error("aborted"); + + // ----- Step 3: Integrity check ----- + if (integrityChecker && settings.enableIntegrityCheck) { + ctx.onStatus("integrity_check", "Integritätsprüfung..."); + ctx.onLog("INFO", "Integrity check started", { targetPath: item.targetPath }); + + try { + const result = await integrityChecker.validateFile(item.targetPath, pkg.outputDir); + if (!result.ok) { + ctx.onLog("ERROR", "Integrity check failed", { message: result.message }); + throw new DownloadError(DownloadErrorKind.FileCorrupt, result.message); + } + ctx.onLog("INFO", "Integrity check passed", { message: result.message }); + } catch (error) { + if (error instanceof DownloadError) throw error; + // Non-DownloadError from integrity check — classify + throw new DownloadError(DownloadErrorKind.FileCorrupt, String(error)); + } + } + + // ----- Done ----- + return { + success: true, + downloadedBytes: streamResult.downloadedBytes, + totalBytes: streamResult.totalBytes, + directUrl, + provider: provider!, + providerLabel, + resumable: streamResult.resumable, + skipTlsVerify, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").replace(/\s+/g, " ").trim() || "download"; +} + +function filenameFromUrl(url: string): string { + try { + const u = new URL(url); + const pathParts = u.pathname.split("/").filter(Boolean); + const last = pathParts[pathParts.length - 1] || "download"; + return decodeURIComponent(last); + } catch { + return "download"; + } +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + const normalizedFile = path.resolve(filePath).toLowerCase(); + const normalizedDir = path.resolve(dirPath).toLowerCase(); + return normalizedFile.startsWith(normalizedDir); +} + +function providerDisplayName(provider: DebridProvider | null): string { + if (!provider) return "Debrid"; + const names: Record = { + realdebrid: "Real-Debrid", + "megadebrid-api": "Mega-Debrid API", + "megadebrid-web": "Mega-Debrid Web", + megadebrid: "Mega-Debrid", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid", + ddownload: "DDownload", + onefichier: "1Fichier", + debridlink: "DebridLink", + linksnappy: "LinkSnappy", + }; + return names[provider] || provider; +} diff --git a/src/main/download/post-processor.ts b/src/main/download/post-processor.ts new file mode 100644 index 0000000..3ebc8f0 --- /dev/null +++ b/src/main/download/post-processor.ts @@ -0,0 +1,409 @@ +/** + * post-processor.ts — Extraction state machine with bounded retries. + * + * Each archive has a clear state (pending → extracting → done/failed). + * No infinite loops: hard cap on retry count per archive. + * Redownload requests are emitted as events, not handled internally. + */ + +import { EventEmitter } from "node:events"; +import { DownloadError, DownloadErrorKind, classifyExtractionError } from "./error-classifier"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ArchiveExtractionState { + archiveName: string; + status: "pending" | "extracting" | "done" | "failed"; + attempts: number; + maxAttempts: number; + redownloaded: boolean; + lastError?: string; + lastErrorKind?: DownloadErrorKind; +} + +export interface PackagePostProcessState { + packageId: string; + status: "idle" | "waiting" | "extracting" | "done" | "failed" | "aborted"; + archives: Map; + startedAt: number; + completedAt?: number; + label?: string; +} + +export interface PostProcessOptions { + packageDir: string; + extractDir: string; + cleanupMode: "none" | "trash" | "delete"; + conflictMode: "overwrite" | "skip" | "rename" | "ask"; + removeLinks: boolean; + removeSamples: boolean; + passwordList: string; + hybridMode: boolean; + maxParallelExtract: number; + extractCpuPriority: string; + signal: AbortSignal; +} + +export interface ExtractProgressUpdate { + current: number; + total: number; + percent: number; + archiveName: string; + archivePercent?: number; + phase: "extracting" | "done" | "preparing"; + archiveDone?: boolean; + archiveSuccess?: boolean; +} + +export interface ExtractArchiveFailure { + archiveName: string; + errorText: string; + category: string; + suggestRedownload: boolean; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_EXTRACT_ATTEMPTS = 3; +const SLOT_POLL_INTERVAL_MS = 500; + +// --------------------------------------------------------------------------- +// PostProcessor +// --------------------------------------------------------------------------- + +export interface PostProcessorEvents { + progress: [{ packageId: string; update: ExtractProgressUpdate }]; + "package-done": [{ packageId: string; success: boolean; errors: string[] }]; + "archive-redownload": [{ packageId: string; archiveName: string; error: string }]; + status: [{ packageId: string; label: string }]; +} + +export class PostProcessor extends EventEmitter { + private states = new Map(); + private abortControllers = new Map(); + private activeTasks = new Map>(); + private activeSlots = 0; + private maxSlots: number; + private slotWaiters: Array<() => void> = []; + + /** Extraction function — injected to avoid circular dependency. */ + private extractFn: ((opts: any) => Promise) | null = null; + /** Archive candidate finder. */ + private findArchivesFn: ((dir: string) => string[] | Promise) | null = null; + + constructor(maxParallel: number = 2) { + super(); + this.maxSlots = maxParallel; + } + + /** Inject the extraction function (from extractor.ts). */ + setExtractor( + extractFn: (opts: any) => Promise, + findArchivesFn: (dir: string) => string[] | Promise, + ): void { + this.extractFn = extractFn; + this.findArchivesFn = findArchivesFn; + } + + setMaxParallel(n: number): void { + this.maxSlots = Math.max(1, n); + } + + /** + * Queue a package for post-processing. + * If already processing, mark for re-run (hybrid requeue). + */ + queuePackage(packageId: string, options: PostProcessOptions): void { + const existing = this.activeTasks.get(packageId); + if (existing) { + // Mark for requeue — current run will check after finishing + const state = this.states.get(packageId); + if (state) state.status = "waiting"; + return; + } + + const ac = new AbortController(); + this.abortControllers.set(packageId, ac); + + const combinedSignal = AbortSignal.any([options.signal, ac.signal]); + + const task = this.runPostProcessing(packageId, { ...options, signal: combinedSignal }); + this.activeTasks.set(packageId, task); + + task.finally(() => { + this.activeTasks.delete(packageId); + this.abortControllers.delete(packageId); + }); + } + + /** + * Abort processing for a specific package. + */ + abortPackage(packageId: string): void { + const ac = this.abortControllers.get(packageId); + if (ac) ac.abort(); + const state = this.states.get(packageId); + if (state) state.status = "aborted"; + } + + /** + * Abort all active post-processing. + */ + abortAll(): void { + for (const [id, ac] of this.abortControllers) { + ac.abort(); + const state = this.states.get(id); + if (state) state.status = "aborted"; + } + } + + /** + * Retry extraction for a package (user-initiated). + */ + retryPackage(packageId: string, options: PostProcessOptions): void { + // Reset archive states + const state = this.states.get(packageId); + if (state) { + for (const archive of state.archives.values()) { + if (archive.status === "failed") { + archive.status = "pending"; + archive.attempts = 0; + } + } + state.status = "idle"; + } + this.queuePackage(packageId, options); + } + + /** + * Get state for a package. + */ + getState(packageId: string): PackagePostProcessState | undefined { + return this.states.get(packageId); + } + + /** + * Check if any processing is active. + */ + isActive(): boolean { + return this.activeTasks.size > 0; + } + + /** + * Wait for all active tasks to complete. + */ + async waitAll(): Promise { + await Promise.allSettled([...this.activeTasks.values()]); + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private async runPostProcessing(packageId: string, options: PostProcessOptions): Promise { + // Acquire slot + await this.acquireSlot(options.signal); + if (options.signal.aborted) return; + + const state: PackagePostProcessState = this.states.get(packageId) || { + packageId, + status: "extracting", + archives: new Map(), + startedAt: Date.now(), + }; + state.status = "extracting"; + state.startedAt = Date.now(); + this.states.set(packageId, state); + + let round = 0; + const MAX_ROUNDS = 5; // Hard cap on requeue rounds + + try { + do { + round++; + if (round > MAX_ROUNDS) { + state.label = `Max. Runden erreicht (${MAX_ROUNDS})`; + break; + } + + this.emit("status", { packageId, label: `Entpacken Runde ${round}...` }); + + try { + await this.runExtractionRound(packageId, options, state); + } catch (error) { + if (options.signal.aborted) break; + const msg = error instanceof Error ? error.message : String(error); + state.label = `Fehler: ${msg}`; + this.emit("status", { packageId, label: state.label }); + } + + // Check if there are pending archives for another round + const hasPending = [...state.archives.values()].some(a => a.status === "pending"); + if (!hasPending) break; + + } while (!options.signal.aborted); + + // Determine final status + const archives = [...state.archives.values()]; + const allDone = archives.every(a => a.status === "done"); + const anyFailed = archives.some(a => a.status === "failed"); + const errors = archives + .filter(a => a.status === "failed") + .map(a => `${a.archiveName}: ${a.lastError || "Unbekannt"}`); + + if (options.signal.aborted) { + state.status = "aborted"; + } else if (allDone || archives.length === 0) { + state.status = "done"; + } else { + state.status = "failed"; + } + state.completedAt = Date.now(); + + this.emit("package-done", { + packageId, + success: state.status === "done", + errors, + }); + + } finally { + this.releaseSlot(); + } + } + + private async runExtractionRound( + packageId: string, + options: PostProcessOptions, + state: PackagePostProcessState, + ): Promise { + if (!this.extractFn || !this.findArchivesFn) { + throw new Error("Extractor not configured — call setExtractor()"); + } + + // Find archives + const archivePaths = await this.findArchivesFn(options.packageDir); + if (archivePaths.length === 0) { + state.label = "Keine Archive gefunden"; + return; + } + + // Initialize archive states for new archives + for (const archivePath of archivePaths) { + const name = archivePath; + if (!state.archives.has(name)) { + state.archives.set(name, { + archiveName: name, + status: "pending", + attempts: 0, + maxAttempts: DEFAULT_MAX_EXTRACT_ATTEMPTS, + redownloaded: false, + }); + } + } + + // Only extract pending archives + const pendingArchives = [...state.archives.values()] + .filter(a => a.status === "pending") + .map(a => a.archiveName); + + if (pendingArchives.length === 0) return; + + // Run extraction + const failures: ExtractArchiveFailure[] = []; + + await this.extractFn({ + packageDir: options.packageDir, + targetDir: options.extractDir, + cleanupMode: options.cleanupMode, + conflictMode: options.conflictMode, + removeLinks: options.removeLinks, + removeSamples: options.removeSamples, + passwordList: options.passwordList, + signal: options.signal, + hybridMode: options.hybridMode, + maxParallel: options.maxParallelExtract, + extractCpuPriority: options.extractCpuPriority, + packageId, + onlyArchives: new Set(pendingArchives), + onProgress: (update: ExtractProgressUpdate) => { + this.emit("progress", { packageId, update }); + + // Track individual archive completion + if (update.archiveDone) { + const archiveState = state.archives.get(update.archiveName); + if (archiveState) { + archiveState.attempts++; + if (update.archiveSuccess) { + archiveState.status = "done"; + } + // If not success, onArchiveFailure will handle it + } + } + }, + onArchiveFailure: (failure: ExtractArchiveFailure) => { + failures.push(failure); + const archiveState = state.archives.get(failure.archiveName); + if (!archiveState) return; + + const error = classifyExtractionError(failure.errorText, failure.category); + archiveState.lastError = failure.errorText; + archiveState.lastErrorKind = error.kind; + archiveState.attempts++; + + // Decide: retry, redownload, or fail permanently + if (archiveState.attempts >= archiveState.maxAttempts) { + // Max attempts reached + if (error.kind === DownloadErrorKind.ArchiveCorrupt && !archiveState.redownloaded && failure.suggestRedownload) { + // Request redownload (max once per archive) + archiveState.redownloaded = true; + archiveState.attempts = 0; // Reset for redownloaded archive + archiveState.status = "pending"; + this.emit("archive-redownload", { + packageId, + archiveName: failure.archiveName, + error: failure.errorText, + }); + } else { + archiveState.status = "failed"; + } + } else { + // Still have attempts left — mark as pending for next round + archiveState.status = "pending"; + } + }, + }); + } + + // ----------------------------------------------------------------------- + // Slot management + // ----------------------------------------------------------------------- + + private async acquireSlot(signal: AbortSignal): Promise { + while (this.activeSlots >= this.maxSlots) { + if (signal.aborted) return; + await new Promise(resolve => { + this.slotWaiters.push(resolve); + // Also poll in case signal gets aborted + const timer = setTimeout(() => { + const idx = this.slotWaiters.indexOf(resolve); + if (idx >= 0) this.slotWaiters.splice(idx, 1); + resolve(); + }, SLOT_POLL_INTERVAL_MS); + // Clean up timer if resolved normally + const originalResolve = resolve; + // Just let the poll handle it + }); + } + this.activeSlots++; + } + + private releaseSlot(): void { + this.activeSlots = Math.max(0, this.activeSlots - 1); + const waiter = this.slotWaiters.shift(); + if (waiter) waiter(); + } +} diff --git a/src/main/download/retry-manager.ts b/src/main/download/retry-manager.ts new file mode 100644 index 0000000..498c0b7 --- /dev/null +++ b/src/main/download/retry-manager.ts @@ -0,0 +1,390 @@ +/** + * retry-manager.ts — Declarative retry logic with per-error-kind policies. + * + * Each DownloadErrorKind has a RetryPolicy that determines max retries, + * backoff strategy, and actions (reset file, switch provider, etc.). + * The RetryManager tracks failure counts per item and decides whether + * to retry or fail permanently. + */ + +import { DownloadError, DownloadErrorKind, errorKindLabel, isPermanentKind } from "./error-classifier"; + +// --------------------------------------------------------------------------- +// Retry Policy +// --------------------------------------------------------------------------- + +export interface RetryPolicy { + /** Maximum retries for this error kind. 0 = fail immediately. */ + maxRetries: number; + /** Backoff strategy. */ + backoff: "fixed" | "exponential"; + /** Base delay in milliseconds. */ + baseDelayMs: number; + /** Maximum delay in milliseconds (cap for exponential). */ + maxDelayMs: number; + /** Delete partial file before retry. */ + resetFile: boolean; + /** Try a different debrid provider on retry. */ + switchProvider: boolean; + /** Request a fresh direct link from debrid service. */ + refreshLink: boolean; + /** Apply cooldown to current provider (ms). 0 = no cooldown. */ + providerCooldownMs: number; +} + +export const RETRY_POLICIES: Record = { + // -- Network -- + [DownloadErrorKind.NetworkReset]: { + maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, + resetFile: true, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.Timeout]: { + maxRetries: 10, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 30_000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.DnsFailure]: { + maxRetries: 2, backoff: "fixed", baseDelayMs: 5000, maxDelayMs: 5000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.ConnectTimeout]: { + maxRetries: 4, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 30_000, + resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + + // -- HTTP -- + [DownloadErrorKind.RangeNotSatisfied]: { + maxRetries: 2, backoff: "fixed", baseDelayMs: 200, maxDelayMs: 200, + resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.RangeIgnored]: { + maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, + resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.ServerError]: { + maxRetries: 5, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 60_000, + resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.RateLimited]: { + maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120_000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.Forbidden]: { + maxRetries: 2, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, + resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.NotFound]: { + maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, + resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + + // -- Provider / Debrid -- + [DownloadErrorKind.UnrestrictFailed]: { + maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120_000, + resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 20_000, + }, + [DownloadErrorKind.ProviderBusy]: { + maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 60_000, + resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 12_000, + }, + [DownloadErrorKind.ProviderDown]: { + maxRetries: 5, backoff: "exponential", baseDelayMs: 10_000, maxDelayMs: 180_000, + resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 30_000, + }, + [DownloadErrorKind.HosterUnavailable]: { + maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 30_000, + resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 15_000, + }, + [DownloadErrorKind.LinkDead]: { + maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.QuotaExceeded]: { + maxRetries: 3, backoff: "exponential", baseDelayMs: 30_000, maxDelayMs: 300_000, + resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 60_000, + }, + + // -- Filesystem -- + [DownloadErrorKind.DiskFull]: { + maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.PermissionDenied]: { + maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.FileLocked]: { + maxRetries: 3, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 10_000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + + // -- Integrity / Resume -- + [DownloadErrorKind.FileCorrupt]: { + maxRetries: 2, backoff: "fixed", baseDelayMs: 500, maxDelayMs: 500, + resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.FileTruncated]: { + maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, + resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.ResumeUnderflow]: { + maxRetries: 2, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, + resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + + // -- Extraction -- + [DownloadErrorKind.WrongPassword]: { + maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + [DownloadErrorKind.ArchiveCorrupt]: { + maxRetries: 1, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, + resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0, + }, + [DownloadErrorKind.ExtractorCrash]: { + maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + + // -- Write / Drain -- + [DownloadErrorKind.WriteDrainTimeout]: { + maxRetries: 3, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 30_000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, + + // -- Catchall -- + [DownloadErrorKind.Unknown]: { + maxRetries: 5, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 60_000, + resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0, + }, +}; + +// --------------------------------------------------------------------------- +// Retry Actions +// --------------------------------------------------------------------------- + +export type RetryAction = + | "reset_file" + | "switch_provider" + | "refresh_link" + | "cooldown_provider" + | "shelve"; + +// --------------------------------------------------------------------------- +// Retry State (per item) +// --------------------------------------------------------------------------- + +export interface RetryState { + failuresByKind: Partial>; + totalFailures: number; + shelveCount: number; + lastErrorKind?: DownloadErrorKind; + lastErrorMessage?: string; +} + +// --------------------------------------------------------------------------- +// Retry Decision +// --------------------------------------------------------------------------- + +export interface RetryDecision { + shouldRetry: boolean; + delayMs: number; + actions: RetryAction[]; + /** Human-readable status message for UI (German). */ + reason: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SHELVE_THRESHOLD = 15; +const SHELVE_DELAY_MS = 90_000; + +// --------------------------------------------------------------------------- +// RetryManager +// --------------------------------------------------------------------------- + +export class RetryManager { + private states = new Map(); + private userRetryLimit: number; + + constructor(retryLimit: number = 0) { + this.userRetryLimit = retryLimit; + } + + /** Update the user-configured retry limit. 0 = unlimited. */ + setRetryLimit(limit: number): void { + this.userRetryLimit = Math.max(0, limit); + } + + /** + * Record a failure and decide whether to retry. + */ + evaluate(itemId: string, error: DownloadError): RetryDecision { + const state = this.getOrCreateState(itemId); + const kind = error.kind; + + // Update state + state.failuresByKind[kind] = (state.failuresByKind[kind] || 0) + 1; + state.totalFailures += 1; + state.lastErrorKind = kind; + state.lastErrorMessage = error.message; + + const kindCount = state.failuresByKind[kind]!; + const policy = RETRY_POLICIES[kind]; + + // Permanent errors — never retry + if (isPermanentKind(kind) || policy.maxRetries === 0) { + return { + shouldRetry: false, + delayMs: 0, + actions: [], + reason: errorKindLabel(kind), + }; + } + + // Determine effective max retries (user limit overrides if set) + const effectiveMax = this.userRetryLimit > 0 + ? Math.min(policy.maxRetries, this.userRetryLimit) + : policy.maxRetries; + + // Check shelving threshold BEFORE individual kind limits + if (state.totalFailures >= SHELVE_THRESHOLD) { + return this.shelve(state, kind); + } + + // Check if this specific kind exhausted its retries + if (kindCount > effectiveMax) { + return { + shouldRetry: false, + delayMs: 0, + actions: [], + reason: `${errorKindLabel(kind)} — Versuche erschöpft (${kindCount}/${effectiveMax})`, + }; + } + + // Retry — compute delay and actions + const delayMs = this.computeDelay(policy, kindCount); + const actions = this.computeActions(policy); + const reason = `${errorKindLabel(kind)}, Retry ${kindCount}/${effectiveMax}`; + + return { shouldRetry: true, delayMs, actions, reason }; + } + + /** + * Reset retry state for an item (manual reset by user). + */ + resetItem(itemId: string): void { + this.states.delete(itemId); + } + + /** + * Get current retry state for persistence. + */ + getState(itemId: string): RetryState | undefined { + return this.states.get(itemId); + } + + /** + * Restore retry state from persisted session. + */ + restoreState(itemId: string, state: RetryState): void { + this.states.set(itemId, { ...state }); + } + + /** + * Export all retry states for persistence. + */ + exportStates(): Record { + const out: Record = {}; + for (const [id, state] of this.states) { + out[id] = { ...state, failuresByKind: { ...state.failuresByKind } }; + } + return out; + } + + /** + * Import retry states from persistence. + */ + importStates(states: Record): void { + this.states.clear(); + for (const [id, state] of Object.entries(states)) { + this.states.set(id, { ...state, failuresByKind: { ...state.failuresByKind } }); + } + } + + /** + * Remove state for deleted/cancelled items. + */ + removeItem(itemId: string): void { + this.states.delete(itemId); + } + + /** + * Soft-reset stale retry state. Halves counters for items that haven't + * failed recently. Called periodically (e.g. every 10 minutes). + */ + softReset(): void { + for (const state of this.states.values()) { + if (state.totalFailures > 0) { + for (const kind of Object.keys(state.failuresByKind) as DownloadErrorKind[]) { + state.failuresByKind[kind] = Math.floor((state.failuresByKind[kind] || 0) / 2); + } + state.totalFailures = Object.values(state.failuresByKind).reduce( + (sum, v) => sum + (v || 0), 0, + ); + } + } + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private getOrCreateState(itemId: string): RetryState { + let state = this.states.get(itemId); + if (!state) { + state = { failuresByKind: {}, totalFailures: 0, shelveCount: 0 }; + this.states.set(itemId, state); + } + return state; + } + + private shelve(state: RetryState, lastKind: DownloadErrorKind): RetryDecision { + // Halve all counters to allow recovery + for (const kind of Object.keys(state.failuresByKind) as DownloadErrorKind[]) { + state.failuresByKind[kind] = Math.floor((state.failuresByKind[kind] || 0) / 2); + } + state.totalFailures = Object.values(state.failuresByKind).reduce( + (sum, v) => sum + (v || 0), 0, + ); + state.shelveCount += 1; + + return { + shouldRetry: true, + delayMs: SHELVE_DELAY_MS, + actions: ["shelve", "switch_provider", "refresh_link"], + reason: `Viele Fehler (${SHELVE_THRESHOLD}+), pausiert für ${SHELVE_DELAY_MS / 1000}s`, + }; + } + + private computeDelay(policy: RetryPolicy, attempt: number): number { + if (policy.backoff === "fixed") { + return policy.baseDelayMs; + } + // Exponential: base * 1.5^(attempt-1) with jitter, capped at max + const base = policy.baseDelayMs * Math.pow(1.5, attempt - 1); + const capped = Math.min(base, policy.maxDelayMs); + const jitter = capped * Math.random() * 0.5; + return Math.floor(Math.max(capped * 0.5, capped - jitter)); + } + + private computeActions(policy: RetryPolicy): RetryAction[] { + const actions: RetryAction[] = []; + if (policy.resetFile) actions.push("reset_file"); + if (policy.switchProvider) actions.push("switch_provider"); + if (policy.refreshLink) actions.push("refresh_link"); + if (policy.providerCooldownMs > 0) actions.push("cooldown_provider"); + return actions; + } +} diff --git a/src/main/download/scheduler.ts b/src/main/download/scheduler.ts new file mode 100644 index 0000000..7ba376d --- /dev/null +++ b/src/main/download/scheduler.ts @@ -0,0 +1,492 @@ +/** + * scheduler.ts — Queue management, slot allocation, and stall detection. + * + * The scheduler runs a loop that fills download slots up to maxParallel, + * monitors heartbeats for stall detection, and provides a global watchdog. + */ + +import { EventEmitter } from "node:events"; +import type { DownloadItem, PackageEntry, PackagePriority, SessionState } from "../../shared/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SchedulerConfig { + maxParallel: number; + stallTimeoutMs: number; + globalStallWatchdogMs: number; +} + +export interface ActiveSlot { + itemId: string; + packageId: string; + abortController: AbortController; + abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none"; + resumable: boolean; + lastHeartbeatAt: number; + bytesAtHeartbeat: number; + blockedOnDiskWrite: boolean; + blockedOnDiskSince: number; +} + +export interface SlotRequest { + itemId: string; + packageId: string; +} + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- + +export class Scheduler extends EventEmitter { + private generation = 0; + private running = false; + private paused = false; + private config: SchedulerConfig; + + // Active downloads + private slots = new Map(); + + // Retry delays + private retryDelays = new Map(); // itemId → readyAtEpochMs + + // Provider cooldowns + private providerCooldowns = new Map(); + + // Reconnect state + private reconnectUntil = 0; + + // Global watchdog state + private lastGlobalProgressBytes = 0; + private lastGlobalProgressAt = 0; + + // Scoped run (only these packages) + private scopedPackageIds = new Set(); + + constructor(config: SchedulerConfig) { + super(); + this.config = { ...config }; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** Update config at runtime (e.g. when user changes maxParallel). */ + updateConfig(partial: Partial): void { + Object.assign(this.config, partial); + } + + /** + * Start the scheduler loop. + * + * @param session Live session state + * @param startItem Callback to start a download for a slot request + * @param scopedIds Optional: only run these package IDs + */ + async start( + session: SessionState, + startItem: (slot: SlotRequest) => void, + scopedIds?: string[], + ): Promise { + this.generation++; + this.running = true; + this.paused = false; + this.scopedPackageIds = new Set(scopedIds || []); + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = Date.now(); + + const myGeneration = this.generation; + const loopIntervalMs = 120; + let lastHeartbeatCheckAt = Date.now(); + let lastSoftResetAt = Date.now(); + + while (this.running && this.generation === myGeneration) { + const now = Date.now(); + + // Paused — just idle + if (this.paused) { + await sleep(loopIntervalMs); + continue; + } + + // Reconnect wait + if (this.reconnectUntil > now) { + await sleep(220); + continue; + } + + // Fill slots + const maxParallel = Math.max(1, this.config.maxParallel); + while (this.slots.size < maxParallel) { + const next = this.findNextItem(session, now); + if (!next) break; + startItem(next); + } + + // Heartbeat / stall check (every 2s) + if (now - lastHeartbeatCheckAt >= 2000) { + this.checkStalls(now); + lastHeartbeatCheckAt = now; + } + + // Global stall watchdog + this.runGlobalWatchdog(now); + + // Soft-reset stale retry delays (every 10 min) + if (now - lastSoftResetAt >= 600_000) { + this.cleanupStaleRetryDelays(now); + lastSoftResetAt = now; + } + + // Check if run is complete + if (this.slots.size === 0) { + const hasQueued = this.hasQueuedItems(session, now); + const hasDelayed = this.hasDelayedItems(session, now); + if (!hasQueued && !hasDelayed) { + this.emit("run-complete"); + break; + } + } + + await sleep(this.slots.size >= maxParallel ? 170 : loopIntervalMs); + } + + this.running = false; + } + + /** + * Stop the scheduler loop (bumps generation to exit). + */ + stop(): void { + this.generation++; + this.running = false; + } + + /** + * Pause/unpause slot allocation. + */ + setPaused(paused: boolean): void { + this.paused = paused; + } + + get isPaused(): boolean { + return this.paused; + } + + get isRunning(): boolean { + return this.running; + } + + // ----------------------------------------------------------------------- + // Slot management + // ----------------------------------------------------------------------- + + /** + * Register an item as actively downloading. + */ + claimSlot(itemId: string, packageId: string, abortController: AbortController): ActiveSlot { + const slot: ActiveSlot = { + itemId, + packageId, + abortController, + abortReason: "none", + resumable: true, + lastHeartbeatAt: Date.now(), + bytesAtHeartbeat: 0, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0, + }; + this.slots.set(itemId, slot); + return slot; + } + + /** + * Release a slot (download finished/failed/cancelled). + */ + releaseSlot(itemId: string): void { + this.slots.delete(itemId); + } + + /** + * Get active slot for an item. + */ + getSlot(itemId: string): ActiveSlot | undefined { + return this.slots.get(itemId); + } + + /** + * Get all active slots. + */ + getActiveSlots(): Map { + return this.slots; + } + + get activeCount(): number { + return this.slots.size; + } + + hasCapacity(): boolean { + return this.slots.size < Math.max(1, this.config.maxParallel); + } + + // ----------------------------------------------------------------------- + // Heartbeat + // ----------------------------------------------------------------------- + + /** + * Record a heartbeat from an active download. + */ + heartbeat(itemId: string, downloadedBytes: number): void { + const slot = this.slots.get(itemId); + if (slot) { + slot.lastHeartbeatAt = Date.now(); + slot.bytesAtHeartbeat = downloadedBytes; + } + } + + // ----------------------------------------------------------------------- + // Retry scheduling + // ----------------------------------------------------------------------- + + /** + * Schedule a retry delay for an item. + */ + scheduleRetry(itemId: string, delayMs: number): void { + this.retryDelays.set(itemId, Date.now() + Math.max(0, delayMs)); + } + + /** + * Check if an item is still delayed. + */ + isDelayed(itemId: string, now?: number): boolean { + const readyAt = this.retryDelays.get(itemId); + if (!readyAt) return false; + return readyAt > (now ?? Date.now()); + } + + /** + * Clear retry delay for an item. + */ + clearRetryDelay(itemId: string): void { + this.retryDelays.delete(itemId); + } + + // ----------------------------------------------------------------------- + // Provider cooldowns + // ----------------------------------------------------------------------- + + /** + * Apply a cooldown to a provider. + */ + applyProviderCooldown(provider: string, cooldownMs: number): void { + const existing = this.providerCooldowns.get(provider) || { cooldownUntil: 0, failureCount: 0 }; + existing.cooldownUntil = Date.now() + cooldownMs; + existing.failureCount++; + this.providerCooldowns.set(provider, existing); + } + + /** + * Get remaining cooldown for a provider (ms). 0 = not in cooldown. + */ + getProviderCooldownRemaining(provider: string): number { + const entry = this.providerCooldowns.get(provider); + if (!entry) return 0; + const remaining = entry.cooldownUntil - Date.now(); + if (remaining <= 0) { + entry.failureCount = 0; + return 0; + } + return remaining; + } + + /** + * Clear cooldown for a provider (after success). + */ + clearProviderCooldown(provider: string): void { + this.providerCooldowns.delete(provider); + } + + // ----------------------------------------------------------------------- + // Reconnect + // ----------------------------------------------------------------------- + + /** + * Enter reconnect wait mode (429/503 backoff). + */ + setReconnectWait(durationMs: number): void { + this.reconnectUntil = Date.now() + durationMs; + } + + /** + * Check if currently in reconnect wait. + */ + isReconnecting(): boolean { + return this.reconnectUntil > Date.now(); + } + + /** + * Get remaining reconnect wait time (ms). + */ + getReconnectRemaining(): number { + return Math.max(0, this.reconnectUntil - Date.now()); + } + + // ----------------------------------------------------------------------- + // Abort helpers + // ----------------------------------------------------------------------- + + /** + * Abort a specific item's download. + */ + abortItem(itemId: string, reason: ActiveSlot["abortReason"]): void { + const slot = this.slots.get(itemId); + if (slot) { + slot.abortReason = reason; + slot.abortController.abort(reason); + } + } + + /** + * Abort all active downloads. + */ + abortAll(reason: ActiveSlot["abortReason"]): void { + for (const slot of this.slots.values()) { + slot.abortReason = reason; + slot.abortController.abort(reason); + } + } + + // ----------------------------------------------------------------------- + // Private: item selection + // ----------------------------------------------------------------------- + + private findNextItem(session: SessionState, now: number): SlotRequest | null { + const priorities: PackagePriority[] = ["high", "normal", "low"]; + + for (const prio of priorities) { + for (const packageId of session.packageOrder) { + const pkg = session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if ((pkg.priority || "normal") !== prio) continue; + if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(packageId)) continue; + + for (const itemId of pkg.itemIds) { + const item = session.items[itemId]; + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (this.slots.has(itemId)) continue; + + // Check retry delay + const retryAt = this.retryDelays.get(itemId); + if (retryAt && retryAt > now) continue; + if (retryAt && retryAt <= now) this.retryDelays.delete(itemId); + + return { itemId, packageId }; + } + } + } + return null; + } + + private hasQueuedItems(session: SessionState, now: number): boolean { + for (const packageId of session.packageOrder) { + const pkg = session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(packageId)) continue; + + for (const itemId of pkg.itemIds) { + const item = session.items[itemId]; + if (!item) continue; + const retryAt = this.retryDelays.get(itemId); + if (retryAt && retryAt > now) continue; + if (item.status === "queued" || item.status === "reconnect_wait") return true; + } + } + return false; + } + + private hasDelayedItems(session: SessionState, now: number): boolean { + for (const [itemId, readyAt] of this.retryDelays) { + if (readyAt <= now) continue; + const item = session.items[itemId]; + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + const pkg = session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.scopedPackageIds.size > 0 && !this.scopedPackageIds.has(item.packageId)) continue; + return true; + } + return false; + } + + // ----------------------------------------------------------------------- + // Private: stall detection + // ----------------------------------------------------------------------- + + private checkStalls(now: number): void { + if (this.config.stallTimeoutMs <= 0) return; + + for (const slot of this.slots.values()) { + if (slot.blockedOnDiskWrite) continue; // Don't count disk waits + const idleMs = now - slot.lastHeartbeatAt; + if (idleMs > this.config.stallTimeoutMs) { + this.emit("stall-detected", { itemId: slot.itemId, idleMs }); + } + } + } + + private runGlobalWatchdog(now: number): void { + if (this.config.globalStallWatchdogMs <= 0) return; + if (this.slots.size === 0) return; + + // Sum total bytes across all active downloads + let totalBytes = 0; + let allDiskBlocked = true; + for (const slot of this.slots.values()) { + totalBytes += slot.bytesAtHeartbeat; + if (!slot.blockedOnDiskWrite) allDiskBlocked = false; + } + + // If all downloads are disk-blocked, don't trigger watchdog + if (allDiskBlocked) return; + + if (totalBytes > this.lastGlobalProgressBytes) { + this.lastGlobalProgressBytes = totalBytes; + this.lastGlobalProgressAt = now; + } else if (now - this.lastGlobalProgressAt > this.config.globalStallWatchdogMs) { + const stalledIds = [...this.slots.values()] + .filter(s => !s.blockedOnDiskWrite) + .map(s => s.itemId); + this.emit("global-stall", { itemIds: stalledIds }); + this.lastGlobalProgressAt = now; // Reset to avoid rapid-fire events + } + } + + // ----------------------------------------------------------------------- + // Private: cleanup + // ----------------------------------------------------------------------- + + private cleanupStaleRetryDelays(now: number): void { + for (const [itemId, readyAt] of this.retryDelays) { + if (readyAt <= now) { + this.retryDelays.delete(itemId); + } + } + // Cleanup stale provider cooldowns + for (const [provider, entry] of this.providerCooldowns) { + if (entry.cooldownUntil <= now) { + this.providerCooldowns.delete(provider); + } + } + } +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/main/download/stream-writer.ts b/src/main/download/stream-writer.ts new file mode 100644 index 0000000..23838b5 --- /dev/null +++ b/src/main/download/stream-writer.ts @@ -0,0 +1,732 @@ +/** + * stream-writer.ts — HTTP streaming with validated resume, NTFS-aligned + * buffered writing, stall detection, and speed limiting. + * + * This module is a pure function with no dependency on DownloadManager state. + * All side effects happen through callbacks. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DownloadError, DownloadErrorKind, classifyFetchError, classifyHttpStatus, classifyRangeIgnored } from "./error-classifier"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const WRITE_BUFFER_SIZE = 512 * 1024; +const ALLOCATION_UNIT_SIZE = 4096; +const STREAM_HIGH_WATER_MARK = 512 * 1024; +const WRITE_FLUSH_TIMEOUT_MS = 2000; +const DISK_BUSY_THRESHOLD_MS = 300; +const DEFAULT_DRAIN_TIMEOUT_MS = 300_000; // 5 min +const MIN_LEGITIMATE_FILE_BYTES = 512; + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +export interface StreamOptions { + /** Direct download URL. */ + url: string; + /** Target file path on disk. */ + targetPath: string; + /** Expected total file size (from unrestrict or previous response). null = unknown. */ + expectedBytes: number | null; + /** Previously downloaded bytes (tracked by caller for resume validation). */ + trackedDownloadedBytes: number; + /** Stall timeout: abort if no data received for this long (ms). 0 = disabled. */ + stallTimeoutMs: number; + /** Connection timeout (ms). 0 = disabled. */ + connectTimeoutMs: number; + /** Skip TLS verification for this request. */ + skipTlsVerify: boolean; + /** Speed limit in bytes/sec. 0 = no limit. */ + speedLimitBps: number; + /** Abort signal from caller. */ + signal: AbortSignal; + /** Called periodically with download progress. */ + onProgress: (downloadedBytes: number, totalBytes: number | null, speedBps: number) => void; + /** Called every ~1-3s even during slow transfer, for watchdog purposes. */ + onHeartbeat: () => void; + /** Called once after HTTP response to report resumability. */ + onResumable: (resumable: boolean) => void; + /** Called if Content-Disposition provides a different filename. */ + onFileNameOverride?: (newName: string) => void; + /** Called to log events. */ + onLog?: (level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record) => void; + /** Called when disk is busy (backpressure). */ + onDiskBusy?: (busy: boolean) => void; + /** Maximum inner retries on same direct URL before escalating. Default: 3. */ + maxDirectUrlRetries?: number; + /** Low throughput timeout: abort if < minBytes in this window (ms). 0 = disabled. */ + lowThroughputTimeoutMs?: number; + /** Minimum bytes required in lowThroughput window. */ + lowThroughputMinBytes?: number; + /** Whether the target filename looks like a large binary (archive, video, etc.). */ + isLargeBinary?: boolean; +} + +export interface StreamResult { + /** Total bytes of the complete file. */ + totalBytes: number | null; + /** Bytes written in this session (not counting resume). */ + downloadedBytes: number; + /** Whether the server supports Range/resume. */ + resumable: boolean; + /** If Content-Disposition provided a new filename. */ + fileName?: string; + /** True if the download completed (all bytes received). */ + completed: boolean; +} + +// --------------------------------------------------------------------------- +// Main function +// --------------------------------------------------------------------------- + +export async function streamToFile(opts: StreamOptions): Promise { + const { + url, + targetPath, + expectedBytes, + trackedDownloadedBytes, + stallTimeoutMs, + connectTimeoutMs, + skipTlsVerify, + speedLimitBps, + signal, + onProgress, + onHeartbeat, + onResumable, + onFileNameOverride, + onLog, + onDiskBusy, + lowThroughputTimeoutMs = 0, + lowThroughputMinBytes = 64 * 1024, + isLargeBinary = false, + } = opts; + + const maxAttempts = opts.maxDirectUrlRetries ?? 3; + const log = onLog ?? (() => {}); + let lastError: DownloadError | null = null; + let overriddenFileName: string | undefined; + let effectiveTargetPath = targetPath; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // ----- Pre-resume validation ----- + let existingBytes = 0; + try { + const stat = await fs.promises.stat(effectiveTargetPath); + existingBytes = stat.size; + } catch { + // file does not exist + } + + // Guard against pre-allocated sparse files: if file is much larger than + // what we actually wrote, truncate to tracked bytes. + if (existingBytes > 0 && trackedDownloadedBytes > 0 && existingBytes > trackedDownloadedBytes + 1_048_576) { + try { + await fs.promises.truncate(effectiveTargetPath, trackedDownloadedBytes); + existingBytes = trackedDownloadedBytes; + log("WARN", "Sparse file truncated to tracked bytes", { + existingBytes: existingBytes, + trackedDownloadedBytes, + }); + } catch { /* best-effort */ } + } + + // If file is smaller than tracked bytes but nonzero — mismatch, could be + // corruption from a crash. For small mismatches (<1MB), restart fresh. + if (existingBytes > 0 && trackedDownloadedBytes > 0 && existingBytes < trackedDownloadedBytes - 1_048_576) { + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + existingBytes = 0; + log("WARN", "File smaller than tracked bytes — deleted for fresh start", { + existingBytes, + trackedDownloadedBytes, + }); + } catch { /* best-effort */ } + } + + // ----- HTTP request ----- + const headers: Record = {}; + if (existingBytes > 0) { + headers.Range = `bytes=${existingBytes}-`; + } + + log("INFO", "HTTP download attempt", { + attempt, + maxAttempts, + url, + targetPath: effectiveTargetPath, + existingBytes, + rangeHeader: headers.Range || "", + }); + + // Check abort before connecting + checkAborted(signal); + + let response: Response; + let connectTimer: NodeJS.Timeout | null = null; + const connectAbortController = new AbortController(); + + // TLS skip management + if (skipTlsVerify) acquireTlsSkip(); + try { + if (connectTimeoutMs > 0) { + connectTimer = setTimeout(() => connectAbortController.abort("connect_timeout"), connectTimeoutMs); + } + response = await fetch(url, { + method: "GET", + headers, + signal: AbortSignal.any([signal, connectAbortController.signal]), + }); + } catch (error) { + // Rethrow abort errors + if (signal.aborted) throw error; + if (String(error).includes("connect_timeout")) { + throw new DownloadError(DownloadErrorKind.ConnectTimeout, "Connection timeout", { originalError: error instanceof Error ? error : undefined }); + } + lastError = classifyFetchError(error); + log("WARN", "HTTP connection failed", { attempt, error: lastError.message }); + if (attempt < maxAttempts) { + await sleep(retryDelayWithJitter(attempt, 200)); + continue; + } + throw lastError; + } finally { + if (skipTlsVerify) releaseTlsSkip(); + if (connectTimer) clearTimeout(connectTimer); + } + + // ----- HTTP status handling ----- + if (!response.ok && response.status !== 206) { + if (response.status === 416 && existingBytes > 0) { + const result = await handle416(response, existingBytes, expectedBytes, log); + if (result) { + onResumable(true); + return result; + } + // Not complete — delete and retry + try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {} + if (attempt < maxAttempts) { + await sleep(retryDelayWithJitter(attempt, 200)); + continue; + } + throw classifyHttpStatus({ status: 416, existingBytes }); + } + + const responseText = await response.text().catch(() => ""); + lastError = classifyHttpStatus({ + status: response.status, + statusText: response.statusText, + responseText, + existingBytes, + }); + log("WARN", "HTTP response not OK", { attempt, status: response.status, error: lastError.message }); + if (attempt < maxAttempts) { + await sleep(retryDelayWithJitter(attempt, 250)); + continue; + } + throw lastError; + } + + // ----- Response analysis ----- + const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes"); + const resumable = response.status === 206 || acceptRanges; + onResumable(resumable); + + // Detect server ignoring Range header (200 instead of 206) + if (existingBytes > 0 && response.status === 200) { + const contentLength = Number(response.headers.get("content-length") || 0); + try { await response.body?.cancel(); } catch {} + log("WARN", "Server ignored Range header", { existingBytes, contentLength }); + throw classifyRangeIgnored(existingBytes, contentLength); + } + + // Parse total size + const rawContentLength = Number(response.headers.get("content-length") || 0); + const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0; + const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); + + let totalBytes = expectedBytes; + if (!totalBytes || totalBytes <= 0) { + if (totalFromRange) totalBytes = totalFromRange; + else if (contentLength > 0) { + totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; + } + } + + // Content-Disposition filename (only on fresh downloads) + if (existingBytes === 0 && onFileNameOverride) { + const rawName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim(); + const fromHeader = rawName ? sanitizeFilename(rawName) : ""; + if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== path.basename(targetPath)) { + overriddenFileName = fromHeader; + const newPath = path.join(path.dirname(targetPath), fromHeader); + effectiveTargetPath = newPath; + onFileNameOverride(fromHeader); + log("INFO", "Filename from Content-Disposition", { fromHeader, newPath }); + } + } + + const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; + + log("INFO", "HTTP response accepted", { + attempt, status: response.status, resumable, contentLength, + totalFromRange, totalBytes, writeMode, + }); + + // If starting fresh, delete existing file + if (writeMode === "w" && existingBytes > 0) { + try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {} + } + + await fs.promises.mkdir(path.dirname(effectiveTargetPath), { recursive: true }); + + // ----- Sparse pre-allocation (Windows) ----- + let preAllocated = false; + if (writeMode === "w" && totalBytes && totalBytes > 0 && process.platform === "win32") { + try { + const fd = await fs.promises.open(effectiveTargetPath, "w"); + try { await fd.truncate(totalBytes); preAllocated = true; } finally { await fd.close(); } + } catch { /* best-effort */ } + } + + // ----- Streaming write ----- + const stream = fs.createWriteStream(effectiveTargetPath, { + flags: preAllocated ? "r+" : writeMode === "a" ? "a" : "w", + start: preAllocated ? 0 : undefined, + highWaterMark: STREAM_HIGH_WATER_MARK, + }); + let written = writeMode === "a" ? existingBytes : 0; + let windowBytes = 0; + let windowStarted = nowMs(); + let bodyError: unknown = null; + + // Write buffer with 4KB NTFS alignment + const writeBuf = Buffer.allocUnsafe(WRITE_BUFFER_SIZE); + let writeBufPos = 0; + let lastFlushAt = nowMs(); + + let diskBusySince = 0; + let diskBusyNotified = false; + const drainTimeoutMs = Math.max(30_000, Math.min(DEFAULT_DRAIN_TIMEOUT_MS, stallTimeoutMs > 0 ? stallTimeoutMs * 12 : 120_000)); + + // --- waitDrain --- + const waitDrain = (): Promise => new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error("aborted")); return; } + + if (onDiskBusy && !diskBusyNotified) { + onDiskBusy(true); + diskBusyNotified = true; + } + + let settled = false; + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new DownloadError(DownloadErrorKind.WriteDrainTimeout, "write_drain_timeout")); + }, drainTimeoutMs); + + const cleanup = (): void => { + clearTimeout(timeoutId); + if (onDiskBusy && diskBusyNotified) { + onDiskBusy(false); + diskBusyNotified = false; + } + stream.off("drain", onDrain); + stream.off("error", onErr); + signal.removeEventListener("abort", onAbort); + }; + const onDrain = (): void => { if (!settled) { settled = true; cleanup(); resolve(); } }; + const onErr = (e: Error): void => { if (!settled) { settled = true; cleanup(); reject(e); } }; + const onAbort = (): void => { if (!settled) { settled = true; cleanup(); reject(new Error("aborted")); } }; + + stream.once("drain", onDrain); + stream.once("error", onErr); + signal.addEventListener("abort", onAbort, { once: true }); + }); + + // --- aligned flush --- + const alignedFlush = async (final = false): Promise => { + if (writeBufPos === 0) return; + let toWrite = writeBufPos; + if (!final && toWrite > ALLOCATION_UNIT_SIZE) { + toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE); + } + const slice = Buffer.from(writeBuf.subarray(0, toWrite)); + if (!stream.write(slice)) { + await waitDrain(); + } + if (toWrite < writeBufPos) { + writeBuf.copy(writeBuf, 0, toWrite, writeBufPos); + } + writeBufPos -= toWrite; + lastFlushAt = nowMs(); + }; + + try { + const body = response.body; + if (!body) throw new DownloadError(DownloadErrorKind.Unknown, "Empty response body"); + + const reader = body.getReader(); + let lastDataAt = nowMs(); + + // Throughput window for low-throughput detection + let throughputWindowStart = nowMs(); + let throughputWindowBytes = 0; + + // Speed limiter state + let speedLimitWindowStart = nowMs(); + let speedLimitWindowBytes = 0; + + // Heartbeat timer + const heartbeatInterval = setInterval(() => { + if (!signal.aborted) onHeartbeat(); + }, 2000); + + // readWithTimeout + const readWithTimeout = async (): Promise> => { + if (stallTimeoutMs <= 0) return reader.read(); + return new Promise>((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new DownloadError(DownloadErrorKind.Timeout, "stall_timeout")); + }, stallTimeoutMs); + reader.read().then(result => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }).catch(err => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + }); + }; + + try { + while (true) { + const { done, value } = await readWithTimeout(); + if (done) break; + + lastDataAt = nowMs(); + checkAborted(signal); + + const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + + // Speed limiting + if (speedLimitBps > 0) { + speedLimitWindowBytes += buffer.length; + const elapsed = (nowMs() - speedLimitWindowStart) / 1000; + if (elapsed > 0.1) { + const currentRate = speedLimitWindowBytes / elapsed; + if (currentRate > speedLimitBps) { + const sleepMs = Math.floor(((speedLimitWindowBytes / speedLimitBps) - elapsed) * 1000); + if (sleepMs > 10) await sleep(Math.min(sleepMs, 1000)); + } + if (elapsed >= 1) { + speedLimitWindowStart = nowMs(); + speedLimitWindowBytes = 0; + } + } + } + + checkAborted(signal); + + // Buffer incoming data for aligned writes + let srcOffset = 0; + while (srcOffset < buffer.length) { + const space = WRITE_BUFFER_SIZE - writeBufPos; + const toCopy = Math.min(space, buffer.length - srcOffset); + buffer.copy(writeBuf, writeBufPos, srcOffset, srcOffset + toCopy); + writeBufPos += toCopy; + srcOffset += toCopy; + if (writeBufPos >= Math.floor(WRITE_BUFFER_SIZE * 0.80)) { + await alignedFlush(false); + } + } + + // Time-based flush + if (writeBufPos > 0 && nowMs() - lastFlushAt >= WRITE_FLUSH_TIMEOUT_MS) { + await alignedFlush(false); + } + + // Proactive disk-busy detection + if (stream.writableLength > 0) { + if (diskBusySince === 0) diskBusySince = nowMs(); + } else { + diskBusySince = 0; + if (diskBusyNotified && onDiskBusy) { + onDiskBusy(false); + diskBusyNotified = false; + } + } + + written += buffer.length; + windowBytes += buffer.length; + throughputWindowBytes += buffer.length; + + // Early completion: all expected bytes received + const expectedTotal = totalBytes && totalBytes > 0 ? totalBytes : 0; + const expectedFromResponse = contentLength > 0 ? contentLength : 0; + if (expectedTotal > 0 && written >= expectedTotal) break; + if (expectedTotal === 0 && expectedFromResponse > 0 && (written - (writeMode === "a" ? existingBytes : 0)) >= expectedFromResponse) break; + + // Low throughput check + const now = nowMs(); + if (lowThroughputTimeoutMs > 0 && now - throughputWindowStart >= lowThroughputTimeoutMs) { + if (throughputWindowBytes < lowThroughputMinBytes) { + throw new DownloadError(DownloadErrorKind.Timeout, `slow_throughput:${throughputWindowBytes}/${lowThroughputMinBytes}`); + } + throughputWindowStart = now; + throughputWindowBytes = 0; + } + + // Speed calculation and progress reporting + const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.2); + const speed = windowBytes / elapsed; + if (elapsed >= 0.5) { + windowStarted = nowMs(); + windowBytes = 0; + } + + const diskBusy = diskBusySince > 0 && nowMs() - diskBusySince >= DISK_BUSY_THRESHOLD_MS; + onProgress(written, totalBytes, diskBusy ? 0 : Math.floor(speed)); + } + } finally { + clearInterval(heartbeatInterval); + try { await reader.cancel().catch(() => {}); reader.releaseLock(); } catch {} + } + } catch (error) { + bodyError = error; + log("WARN", "Download body error", { attempt, error: errorMessage(error) }); + } finally { + // Flush remaining buffered data + try { await alignedFlush(true); } catch (e) { if (!bodyError) bodyError = e; } + + // Close stream + try { + await new Promise((resolve, reject) => { + if (stream.closed || stream.destroyed) { resolve(); return; } + const onDone = (): void => { stream.off("error", onErr); resolve(); }; + const onErr = (e: Error): void => { stream.off("finish", onDone); stream.off("close", onDone); reject(e); }; + stream.once("finish", onDone); + stream.once("close", onDone); + stream.once("error", onErr); + stream.end(); + }); + } catch (closeErr) { + if (!stream.destroyed) stream.destroy(); + if (!bodyError) throw closeErr; + log("WARN", "Stream close error suppressed", { error: errorMessage(closeErr) }); + } + + if (!stream.destroyed) stream.destroy(); + + // fsync for pre-allocated files + if (!bodyError && preAllocated) { + try { + const fd = await fs.promises.open(effectiveTargetPath, "r"); + try { await fd.datasync(); } finally { await fd.close(); } + } catch { /* best-effort */ } + } + + // Truncate pre-allocated file to actual written bytes on error + if (bodyError && preAllocated && totalBytes && written < totalBytes) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch {} + } + + if (bodyError) { + // On error: truncate pre-allocated sparse file + if (preAllocated && totalBytes && written < totalBytes) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch {} + } + if (signal.aborted) throw bodyError; + lastError = bodyError instanceof DownloadError ? bodyError : classifyFetchError(bodyError); + if (attempt < maxAttempts) { + log("WARN", "Retrying after body error", { attempt, error: lastError.message }); + await sleep(retryDelayWithJitter(attempt, 250)); + continue; + } + throw lastError; + } + } + + // ----- Post-download validation ----- + + // Tiny file detection (hoster error pages disguised as downloads) + if (written > 0 && written < MIN_LEGITIMATE_FILE_BYTES) { + let snippet = ""; + try { snippet = (await fs.promises.readFile(effectiveTargetPath, "utf8")).slice(0, 200).replace(/[\r\n]+/g, " ").trim(); } catch {} + try { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch {} + log("WARN", `Tiny download detected (${written} bytes)`, { snippet }); + throw new DownloadError(DownloadErrorKind.ServerError, + `Download too small (${written} B) — hoster error page?${snippet ? ` Content: "${snippet}"` : ""}`, + { httpStatus: 200 }); + } + + // Underflow detection + if (totalBytes && totalBytes > 0 && written < totalBytes) { + const shortfall = totalBytes - written; + if (preAllocated) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch {} + } + if (isLargeBinary || shortfall > ALLOCATION_UNIT_SIZE) { + log("WARN", "Download underflow", { expected: totalBytes, received: written, shortfall }); + throw new DownloadError(DownloadErrorKind.FileTruncated, + `download_underflow:${written}/${totalBytes}`, + { context: { written, totalBytes, shortfall } }); + } + } + + // Truncate pre-allocated file to actual size + if (preAllocated && totalBytes && written < totalBytes) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch {} + } + + log("INFO", "Download complete", { attempt, resumable, written, totalBytes, targetPath: effectiveTargetPath }); + + return { + totalBytes, + downloadedBytes: written, + resumable, + fileName: overriddenFileName, + completed: true, + }; + } + + // All attempts exhausted + throw lastError ?? new DownloadError(DownloadErrorKind.Unknown, "Download failed — all attempts exhausted"); +} + +// --------------------------------------------------------------------------- +// HTTP 416 handler +// --------------------------------------------------------------------------- + +async function handle416( + response: Response, + existingBytes: number, + expectedBytes: number | null, + log: (level: "INFO" | "WARN" | "ERROR", msg: string, fields?: Record) => void, +): Promise { + await response.arrayBuffer().catch(() => undefined); + const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); + const resolvedTotal = (expectedBytes && expectedBytes > 0) ? expectedBytes : rangeTotal; + + // File is already complete + if (resolvedTotal && existingBytes === resolvedTotal) { + log("INFO", "HTTP 416 treated as complete", { existingBytes, resolvedTotal }); + return { + totalBytes: resolvedTotal, + downloadedBytes: existingBytes, + resumable: true, + completed: true, + }; + } + + // No size info but substantial data — assume complete to avoid deleting multi-GB files + if (!resolvedTotal && existingBytes > 1_048_576) { + log("WARN", "HTTP 416 without size info — assuming complete", { existingBytes }); + return { + totalBytes: existingBytes, + downloadedBytes: existingBytes, + resumable: true, + completed: true, + }; + } + + // Not complete — caller should delete and retry + return null; +} + +// --------------------------------------------------------------------------- +// Content-Disposition parser (RFC 2231 support) +// --------------------------------------------------------------------------- + +function parseContentDispositionFilename(header: string | null): string { + if (!header) return ""; + // filename*= (RFC 2231 extended notation) + const extMatch = /filename\*\s*=\s*(?:UTF-8|utf-8)?''(.+?)(?:;|$)/i.exec(header); + if (extMatch) { + try { return decodeURIComponent(extMatch[1]); } catch {} + } + // filename= (standard, possibly quoted) + const stdMatch = /filename\s*=\s*"?([^";]+)"?/i.exec(header); + if (stdMatch) return stdMatch[1].trim(); + return ""; +} + +function parseContentRangeTotal(header: string | null): number | null { + if (!header) return null; + const match = /\/\s*(\d+)/.exec(header); + if (match) { + const total = Number(match[1]); + return total > 0 ? total : null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Filename utilities +// --------------------------------------------------------------------------- + +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").replace(/\s+/g, " ").trim(); +} + +function looksLikeOpaqueFilename(name: string): boolean { + return /^[a-f0-9]{20,}(\.\w+)?$/i.test(name); +} + +// --------------------------------------------------------------------------- +// TLS skip reference counter +// --------------------------------------------------------------------------- + +let tlsSkipRefCount = 0; + +function acquireTlsSkip(): void { + tlsSkipRefCount++; + if (tlsSkipRefCount === 1) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +} + +function releaseTlsSkip(): void { + tlsSkipRefCount--; + if (tlsSkipRefCount <= 0) { + tlsSkipRefCount = 0; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function checkAborted(signal: AbortSignal): void { + if (signal.aborted) throw new Error("aborted"); +} + +function nowMs(): number { + return Date.now(); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function retryDelayWithJitter(attempt: number, baseMs: number): number { + const base = baseMs * Math.pow(1.5, attempt - 1); + const jitter = base * Math.random(); + return Math.floor(Math.max(base * 0.5, base - jitter)); +} + +function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e ?? ""); +} diff --git a/tests/error-classifier.test.ts b/tests/error-classifier.test.ts new file mode 100644 index 0000000..a76b113 --- /dev/null +++ b/tests/error-classifier.test.ts @@ -0,0 +1,705 @@ +import { describe, expect, it } from "vitest"; +import { + DownloadError, + DownloadErrorKind, + classifyFetchError, + classifyHttpStatus, + classifyUnrestrictError, + classifyExtractionError, + classifyRangeIgnored, + ensureDownloadError, + errorKindLabel, + isPermanentKind, +} from "../src/main/download/error-classifier"; + +// =========================================================================== +// DownloadError construction and properties +// =========================================================================== + +describe("DownloadError", () => { + it("stores kind, message, and defaults retryable/permanent from isPermanentKind", () => { + const err = new DownloadError(DownloadErrorKind.NetworkReset, "socket hang up"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("DownloadError"); + expect(err.kind).toBe(DownloadErrorKind.NetworkReset); + expect(err.message).toBe("socket hang up"); + expect(err.retryable).toBe(true); + expect(err.permanent).toBe(false); + }); + + it("marks permanent kinds as non-retryable by default", () => { + const err = new DownloadError(DownloadErrorKind.LinkDead, "file deleted"); + expect(err.retryable).toBe(false); + expect(err.permanent).toBe(true); + }); + + it("stores httpStatus when provided", () => { + const err = new DownloadError(DownloadErrorKind.ServerError, "HTTP 500", { + httpStatus: 500, + }); + expect(err.httpStatus).toBe(500); + }); + + it("stores originalError when provided", () => { + const orig = new Error("root cause"); + const err = new DownloadError(DownloadErrorKind.Unknown, "wrapped", { + originalError: orig, + }); + expect(err.originalError).toBe(orig); + }); + + it("stores arbitrary context", () => { + const err = new DownloadError(DownloadErrorKind.RangeNotSatisfied, "range", { + context: { existingBytes: 1024, expectedTotal: 2048 }, + }); + expect(err.context).toEqual({ existingBytes: 1024, expectedTotal: 2048 }); + }); + + it("allows overriding retryable and permanent via opts", () => { + // Override a normally-permanent kind to be retryable + const err = new DownloadError(DownloadErrorKind.DiskFull, "disk full", { + retryable: true, + permanent: false, + }); + expect(err.retryable).toBe(true); + expect(err.permanent).toBe(false); + }); + + it("httpStatus is undefined when not provided", () => { + const err = new DownloadError(DownloadErrorKind.Unknown, "x"); + expect(err.httpStatus).toBeUndefined(); + }); + + it("toLogString produces a compact representation", () => { + const err = new DownloadError(DownloadErrorKind.ServerError, "Internal Server Error", { + httpStatus: 500, + }); + const log = err.toLogString(); + expect(log).toContain("[server_error]"); + expect(log).toContain("Internal Server Error"); + expect(log).toContain("(HTTP 500)"); + }); + + it("toLogString omits HTTP status when not set", () => { + const err = new DownloadError(DownloadErrorKind.Timeout, "stalled"); + const log = err.toLogString(); + expect(log).toBe("[timeout] stalled"); + expect(log).not.toContain("HTTP"); + }); +}); + +// =========================================================================== +// classifyFetchError +// =========================================================================== + +describe("classifyFetchError", () => { + // ---- Network Reset ---- + it.each([ + "socket hang up", + "ECONNRESET", + "ECONNREFUSED", + "EPIPE broken pipe", + "network error on fetch", + "socket closed unexpectedly", + "connection reset by peer", + "fetch failed", + ])("classifies '%s' as NetworkReset", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.NetworkReset); + expect(err.retryable).toBe(true); + }); + + // ---- Connection Timeout ---- + it.each([ + "ETIMEDOUT", + "connect_timeout reached", + "Connection timed out after 30s", + ])("classifies '%s' as ConnectTimeout", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout); + expect(err.retryable).toBe(true); + }); + + // ---- DNS Failure ---- + it.each([ + "getaddrinfo ENOTFOUND example.com", + "ENOTFOUND", + "DNS lookup failed", + ])("classifies '%s' as DnsFailure", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.DnsFailure); + expect(err.retryable).toBe(true); + }); + + // ---- Stall / Read Timeout ---- + it.each([ + "stall_timeout after 60s", + "read timeout waiting for data", + ])("classifies '%s' as Timeout", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.Timeout); + expect(err.retryable).toBe(true); + }); + + // ---- Write Drain Timeout ---- + it("classifies write_drain_timeout as WriteDrainTimeout", () => { + const err = classifyFetchError(new Error("write_drain_timeout: disk slow")); + expect(err.kind).toBe(DownloadErrorKind.WriteDrainTimeout); + expect(err.retryable).toBe(true); + }); + + // ---- Disk Full ---- + it.each([ + "ENOSPC: no space left on device", + "no space left on device", + ])("classifies '%s' as DiskFull (permanent)", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.DiskFull); + expect(err.permanent).toBe(true); + expect(err.retryable).toBe(false); + }); + + // ---- Permission Denied ---- + it.each([ + "EACCES: permission denied '/tmp/f'", + "EPERM: operation not permitted", + "Permission denied writing to output", + ])("classifies '%s' as PermissionDenied (permanent)", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.PermissionDenied); + expect(err.permanent).toBe(true); + }); + + // ---- File Locked ---- + it.each([ + "EBUSY: resource busy or locked", + "file is locked by another process", + "being used by another process", + ])("classifies '%s' as FileLocked", (msg) => { + const err = classifyFetchError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.FileLocked); + expect(err.retryable).toBe(true); + }); + + // ---- Resume Underflow ---- + it("classifies resume_download_underflow as ResumeUnderflow", () => { + const err = classifyFetchError(new Error("resume_download_underflow:512/1024")); + expect(err.kind).toBe(DownloadErrorKind.ResumeUnderflow); + }); + + // ---- Range Ignored ---- + it("classifies range_ignored_on_resume as RangeIgnored", () => { + const err = classifyFetchError(new Error("range_ignored_on_resume:512/2048")); + expect(err.kind).toBe(DownloadErrorKind.RangeIgnored); + }); + + // ---- Unknown ---- + it("classifies an unrecognised message as Unknown", () => { + const err = classifyFetchError(new Error("something completely new")); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + expect(err.retryable).toBe(true); + }); + + // ---- Abort handling ---- + it("re-throws abort errors instead of classifying", () => { + expect(() => classifyFetchError(new Error("Aborted: user cancelled"))).toThrow(); + }); + + it("re-throws abort errors for a plain 'abort' message", () => { + expect(() => classifyFetchError(new Error("abort"))).toThrow(); + }); + + // ---- Non-Error inputs ---- + it("handles a plain string as input", () => { + const err = classifyFetchError("ECONNRESET"); + expect(err.kind).toBe(DownloadErrorKind.NetworkReset); + }); + + it("handles null/undefined gracefully", () => { + const err = classifyFetchError(null); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); + + it("handles undefined gracefully", () => { + const err = classifyFetchError(undefined); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); + + it("preserves originalError reference", () => { + const orig = new Error("ECONNRESET"); + const err = classifyFetchError(orig); + expect(err.originalError).toBe(orig); + }); +}); + +// =========================================================================== +// classifyHttpStatus +// =========================================================================== + +describe("classifyHttpStatus", () => { + it("classifies 416 as RangeNotSatisfied", () => { + const err = classifyHttpStatus({ status: 416 }); + expect(err.kind).toBe(DownloadErrorKind.RangeNotSatisfied); + expect(err.httpStatus).toBe(416); + }); + + it("stores existingBytes in context for 416", () => { + const err = classifyHttpStatus({ status: 416, existingBytes: 4096 }); + expect(err.context).toEqual({ existingBytes: 4096 }); + }); + + it("classifies 429 as RateLimited", () => { + const err = classifyHttpStatus({ status: 429, statusText: "Too Many Requests" }); + expect(err.kind).toBe(DownloadErrorKind.RateLimited); + expect(err.httpStatus).toBe(429); + }); + + it("classifies 403 as Forbidden", () => { + const err = classifyHttpStatus({ status: 403 }); + expect(err.kind).toBe(DownloadErrorKind.Forbidden); + expect(err.httpStatus).toBe(403); + }); + + it("classifies 404 as NotFound", () => { + const err = classifyHttpStatus({ status: 404, statusText: "Not Found" }); + expect(err.kind).toBe(DownloadErrorKind.NotFound); + expect(err.httpStatus).toBe(404); + }); + + it("classifies 500 as ServerError", () => { + const err = classifyHttpStatus({ status: 500 }); + expect(err.kind).toBe(DownloadErrorKind.ServerError); + expect(err.httpStatus).toBe(500); + }); + + it("classifies 502 as ServerError", () => { + const err = classifyHttpStatus({ status: 502, statusText: "Bad Gateway" }); + expect(err.kind).toBe(DownloadErrorKind.ServerError); + expect(err.httpStatus).toBe(502); + }); + + it("classifies 503 as ServerError", () => { + const err = classifyHttpStatus({ status: 503, statusText: "Service Unavailable" }); + expect(err.kind).toBe(DownloadErrorKind.ServerError); + expect(err.httpStatus).toBe(503); + }); + + it("classifies 401 as Unknown (no special branch)", () => { + const err = classifyHttpStatus({ status: 401 }); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + expect(err.httpStatus).toBe(401); + }); + + it("includes responseText in the message when provided", () => { + const err = classifyHttpStatus({ status: 500, responseText: "Internal Server Error" }); + expect(err.message).toContain("500"); + expect(err.message).toContain("Internal Server Error"); + }); + + it("uses statusText as fallback when responseText is absent", () => { + const err = classifyHttpStatus({ status: 500, statusText: "Server Error" }); + expect(err.message).toContain("Server Error"); + }); + + it("produces message without body when neither responseText nor statusText is given", () => { + const err = classifyHttpStatus({ status: 500 }); + expect(err.message).toBe("HTTP 500"); + }); + + it("all server errors (5xx) are retryable", () => { + for (const code of [500, 502, 503, 504]) { + const err = classifyHttpStatus({ status: code }); + expect(err.retryable).toBe(true); + } + }); +}); + +// =========================================================================== +// classifyRangeIgnored +// =========================================================================== + +describe("classifyRangeIgnored", () => { + it("returns RangeIgnored kind", () => { + const err = classifyRangeIgnored(1024, 4096); + expect(err.kind).toBe(DownloadErrorKind.RangeIgnored); + }); + + it("includes existingBytes and contentLength in the message", () => { + const err = classifyRangeIgnored(512, 2048); + expect(err.message).toContain("512"); + expect(err.message).toContain("2048"); + }); + + it("stores existingBytes and contentLength in context", () => { + const err = classifyRangeIgnored(1024, 8192); + expect(err.context).toEqual({ existingBytes: 1024, contentLength: 8192 }); + }); + + it("is retryable by default", () => { + const err = classifyRangeIgnored(0, 100); + expect(err.retryable).toBe(true); + expect(err.permanent).toBe(false); + }); +}); + +// =========================================================================== +// classifyUnrestrictError +// =========================================================================== + +describe("classifyUnrestrictError", () => { + // ---- LinkDead (permanent) ---- + it.each([ + "File not found", + "file unavailable", + "Link is dead", + "File has been removed", + "file has been deleted", + "file is no longer available", + "file was removed from server", + "file was deleted by owner", + "permanent ungültig", + ])("classifies '%s' as LinkDead (permanent)", (msg) => { + const err = classifyUnrestrictError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.LinkDead); + expect(err.permanent).toBe(true); + expect(err.retryable).toBe(false); + }); + + // ---- ProviderBusy ---- + it.each([ + "too many active downloads", + "too many concurrent sessions", + "too many downloads at once", + "active download limit", + "concurrent limit exceeded", + "slot limit reached for this host", + "limit reached try later", + "zu viele aktive Downloads", + "zu viele gleichzeitige Transfers", + "zu viele Downloads", + ])("classifies '%s' as ProviderBusy", (msg) => { + const err = classifyUnrestrictError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.ProviderBusy); + expect(err.retryable).toBe(true); + }); + + // ---- HosterUnavailable ---- + it("classifies 'hosternotavailable' as HosterUnavailable", () => { + const err = classifyUnrestrictError(new Error("hosternotavailable")); + expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable); + expect(err.retryable).toBe(true); + }); + + // ---- QuotaExceeded ---- + it.each([ + "quota exceeded for today", + "bandwidth limit exceeded", + ])("classifies '%s' as QuotaExceeded", (msg) => { + const err = classifyUnrestrictError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.QuotaExceeded); + expect(err.retryable).toBe(true); + }); + + // ---- ProviderDown ---- + it.each([ + "server error occurred", + "internal server error", + "temporarily unavailable", + "temporary unavailable please wait", + "temporarily disabled", + "try again later", + "service unavailable", + "host is down", + "maintenance in progress", + "bad gateway", + "gateway timeout", + "cloudflare challenge detected", + "worker error at edge", + ])("classifies '%s' as ProviderDown", (msg) => { + const err = classifyUnrestrictError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.ProviderDown); + expect(err.retryable).toBe(true); + }); + + // ---- UnrestrictFailed ---- + it.each([ + "unrestrict call failed", + "mega-web provider error", + "mega-debrid session lost", + "bestdebrid API error", + "alldebrid unrestrict failed", + "kein debrid-provider verfügbar", + "session-cookie expired", + "session cookie invalid", + "session blockiert", + "session expired please re-login", + "invalid session token", + "login ungültig", + "login liefert HTTP 401", + "login required for this host", + "login failed with credentials", + ])("classifies '%s' as UnrestrictFailed", (msg) => { + const err = classifyUnrestrictError(new Error(msg)); + expect(err.kind).toBe(DownloadErrorKind.UnrestrictFailed); + expect(err.retryable).toBe(true); + }); + + // ---- Unknown ---- + it("classifies unrecognised debrid error as Unknown", () => { + const err = classifyUnrestrictError(new Error("completely unknown debrid error")); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); + + // ---- Non-Error inputs ---- + it("handles a plain string as input", () => { + const err = classifyUnrestrictError("hosternotavailable"); + expect(err.kind).toBe(DownloadErrorKind.HosterUnavailable); + }); + + it("handles null input gracefully", () => { + const err = classifyUnrestrictError(null); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); +}); + +// =========================================================================== +// classifyExtractionError +// =========================================================================== + +describe("classifyExtractionError", () => { + // ---- WrongPassword (permanent) ---- + it("classifies 'wrong password' as WrongPassword", () => { + const err = classifyExtractionError("Wrong password for archive.rar"); + expect(err.kind).toBe(DownloadErrorKind.WrongPassword); + expect(err.permanent).toBe(true); + expect(err.retryable).toBe(false); + }); + + it("classifies 'falsches Passwort' as WrongPassword", () => { + const err = classifyExtractionError("Falsches Passwort eingegeben"); + expect(err.kind).toBe(DownloadErrorKind.WrongPassword); + expect(err.permanent).toBe(true); + }); + + it("classifies category 'wrong_password' as WrongPassword even with generic message", () => { + const err = classifyExtractionError("extraction error", "wrong_password"); + expect(err.kind).toBe(DownloadErrorKind.WrongPassword); + expect(err.permanent).toBe(true); + }); + + // ---- ArchiveCorrupt ---- + it.each([ + "archive is corrupt", + "unexpected end of archive", + "broken header in rar", + "invalid archive format", + "bad signature in header", + "Archiv beschädigt", + ])("classifies '%s' as ArchiveCorrupt", (msg) => { + const err = classifyExtractionError(msg); + expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt); + expect(err.retryable).toBe(true); + }); + + it("classifies category 'archive_corrupt' as ArchiveCorrupt", () => { + const err = classifyExtractionError("some error", "archive_corrupt"); + expect(err.kind).toBe(DownloadErrorKind.ArchiveCorrupt); + }); + + // ---- ExtractorCrash ---- + it.each([ + "process exited with code 1", + "process crashed unexpectedly", + "extractor failed to start", + "Segmentation fault (core dumped)", + ])("classifies '%s' as ExtractorCrash", (msg) => { + const err = classifyExtractionError(msg); + expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash); + expect(err.retryable).toBe(true); + }); + + it("classifies category 'extractor_crash' as ExtractorCrash", () => { + const err = classifyExtractionError("unknown", "extractor_crash"); + expect(err.kind).toBe(DownloadErrorKind.ExtractorCrash); + }); + + // ---- DiskFull ---- + it.each([ + "ENOSPC: write failed", + "No space left on device", + ])("classifies '%s' as DiskFull (permanent)", (msg) => { + const err = classifyExtractionError(msg); + expect(err.kind).toBe(DownloadErrorKind.DiskFull); + expect(err.permanent).toBe(true); + expect(err.retryable).toBe(false); + }); + + // ---- Unknown ---- + it("classifies unrecognised extraction error as Unknown", () => { + const err = classifyExtractionError("some new error we haven't seen"); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); + + it("handles empty string input", () => { + const err = classifyExtractionError(""); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); +}); + +// =========================================================================== +// ensureDownloadError +// =========================================================================== + +describe("ensureDownloadError", () => { + it("returns existing DownloadError unchanged", () => { + const orig = new DownloadError(DownloadErrorKind.Timeout, "timed out"); + const result = ensureDownloadError(orig); + expect(result).toBe(orig); + }); + + it("wraps a plain Error via classifyFetchError", () => { + const result = ensureDownloadError(new Error("ECONNRESET")); + expect(result).toBeInstanceOf(DownloadError); + expect(result.kind).toBe(DownloadErrorKind.NetworkReset); + }); + + it("wraps a string via classifyFetchError", () => { + const result = ensureDownloadError("ETIMEDOUT"); + expect(result).toBeInstanceOf(DownloadError); + expect(result.kind).toBe(DownloadErrorKind.ConnectTimeout); + }); + + it("wraps null as Unknown", () => { + const result = ensureDownloadError(null); + expect(result).toBeInstanceOf(DownloadError); + expect(result.kind).toBe(DownloadErrorKind.Unknown); + }); + + it("re-throws abort errors (inherits classifyFetchError behavior)", () => { + expect(() => ensureDownloadError(new Error("abort"))).toThrow(); + }); +}); + +// =========================================================================== +// errorKindLabel +// =========================================================================== + +describe("errorKindLabel", () => { + it("returns a non-empty string for every DownloadErrorKind", () => { + for (const kind of Object.values(DownloadErrorKind)) { + const label = errorKindLabel(kind); + expect(label).toBeTruthy(); + expect(typeof label).toBe("string"); + expect(label.length).toBeGreaterThan(0); + } + }); + + it("returns specific labels for known kinds", () => { + expect(errorKindLabel(DownloadErrorKind.NetworkReset)).toBe("Netzwerkfehler"); + expect(errorKindLabel(DownloadErrorKind.DiskFull)).toBe("Festplatte voll"); + expect(errorKindLabel(DownloadErrorKind.WrongPassword)).toBe("Falsches Archiv-Passwort"); + expect(errorKindLabel(DownloadErrorKind.RateLimited)).toBe("Rate-Limit erreicht"); + expect(errorKindLabel(DownloadErrorKind.Unknown)).toBe("Unbekannter Fehler"); + }); + + it("falls back to 'Unbekannter Fehler' for an unrecognised kind", () => { + const label = errorKindLabel("made_up_kind" as DownloadErrorKind); + expect(label).toBe("Unbekannter Fehler"); + }); +}); + +// =========================================================================== +// isPermanentKind +// =========================================================================== + +describe("isPermanentKind", () => { + it("returns true for LinkDead", () => { + expect(isPermanentKind(DownloadErrorKind.LinkDead)).toBe(true); + }); + + it("returns true for DiskFull", () => { + expect(isPermanentKind(DownloadErrorKind.DiskFull)).toBe(true); + }); + + it("returns true for PermissionDenied", () => { + expect(isPermanentKind(DownloadErrorKind.PermissionDenied)).toBe(true); + }); + + it("returns true for WrongPassword", () => { + expect(isPermanentKind(DownloadErrorKind.WrongPassword)).toBe(true); + }); + + it("returns false for retryable kinds", () => { + const retryableKinds = [ + DownloadErrorKind.NetworkReset, + DownloadErrorKind.Timeout, + DownloadErrorKind.DnsFailure, + DownloadErrorKind.ConnectTimeout, + DownloadErrorKind.RangeNotSatisfied, + DownloadErrorKind.RangeIgnored, + DownloadErrorKind.ServerError, + DownloadErrorKind.RateLimited, + DownloadErrorKind.Forbidden, + DownloadErrorKind.NotFound, + DownloadErrorKind.UnrestrictFailed, + DownloadErrorKind.ProviderBusy, + DownloadErrorKind.ProviderDown, + DownloadErrorKind.HosterUnavailable, + DownloadErrorKind.QuotaExceeded, + DownloadErrorKind.FileLocked, + DownloadErrorKind.FileCorrupt, + DownloadErrorKind.FileTruncated, + DownloadErrorKind.ResumeUnderflow, + DownloadErrorKind.ArchiveCorrupt, + DownloadErrorKind.ExtractorCrash, + DownloadErrorKind.WriteDrainTimeout, + DownloadErrorKind.Unknown, + ]; + for (const kind of retryableKinds) { + expect(isPermanentKind(kind)).toBe(false); + } + }); +}); + +// =========================================================================== +// Edge cases and priority +// =========================================================================== + +describe("classifier priority / edge cases", () => { + it("classifyFetchError checks abort before other patterns", () => { + // "abort" appears before network patterns, so abort should win + expect(() => classifyFetchError(new Error("Aborted: ECONNRESET"))).toThrow(); + }); + + it("classifyFetchError: ETIMEDOUT wins over ECONNRESET when both keywords present", () => { + // ConnectTimeout is checked before NetworkReset in the code + const err = classifyFetchError(new Error("ETIMEDOUT ECONNRESET")); + expect(err.kind).toBe(DownloadErrorKind.ConnectTimeout); + }); + + it("classifyFetchError: DNS checked before NetworkReset", () => { + const err = classifyFetchError(new Error("getaddrinfo ENOTFOUND fetch failed")); + expect(err.kind).toBe(DownloadErrorKind.DnsFailure); + }); + + it("classifyFetchError: ENOSPC checked before generic unknown", () => { + const err = classifyFetchError(new Error("write error ENOSPC")); + expect(err.kind).toBe(DownloadErrorKind.DiskFull); + }); + + it("classifyExtractionError: wrong_password category overrides message text", () => { + // Even if message contains 'corrupt', category should take priority + const err = classifyExtractionError("archive is corrupt", "wrong_password"); + expect(err.kind).toBe(DownloadErrorKind.WrongPassword); + }); + + it("classifyHttpStatus: treats status 599 as ServerError (>= 500 rule)", () => { + const err = classifyHttpStatus({ status: 599 }); + expect(err.kind).toBe(DownloadErrorKind.ServerError); + }); + + it("classifyHttpStatus: treats status 200 as Unknown", () => { + const err = classifyHttpStatus({ status: 200 }); + expect(err.kind).toBe(DownloadErrorKind.Unknown); + }); +}); diff --git a/tests/retry-manager.test.ts b/tests/retry-manager.test.ts new file mode 100644 index 0000000..b13ab70 --- /dev/null +++ b/tests/retry-manager.test.ts @@ -0,0 +1,812 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + DownloadError, + DownloadErrorKind, +} from "../src/main/download/error-classifier"; +import { + RetryManager, + RETRY_POLICIES, + RetryPolicy, + RetryState, +} from "../src/main/download/retry-manager"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** All values of DownloadErrorKind. */ +const ALL_KINDS = Object.values(DownloadErrorKind) as DownloadErrorKind[]; + +/** Convenience: create a DownloadError for a given kind. */ +function mkError(kind: DownloadErrorKind, msg = "test error"): DownloadError { + return new DownloadError(kind, msg); +} + +/** Feed N failures of the same kind and return the last decision. */ +function failNTimes( + mgr: RetryManager, + itemId: string, + kind: DownloadErrorKind, + n: number, +) { + let last; + for (let i = 0; i < n; i++) { + last = mgr.evaluate(itemId, mkError(kind)); + } + return last!; +} + +// --------------------------------------------------------------------------- +// 1) RETRY_POLICIES — completeness +// --------------------------------------------------------------------------- + +describe("RETRY_POLICIES", () => { + it("has a policy defined for every DownloadErrorKind value", () => { + for (const kind of ALL_KINDS) { + expect(RETRY_POLICIES[kind], `missing policy for ${kind}`).toBeDefined(); + } + }); + + it("every policy has valid shape", () => { + for (const kind of ALL_KINDS) { + const p = RETRY_POLICIES[kind]; + expect(p.maxRetries).toBeGreaterThanOrEqual(0); + expect(["fixed", "exponential"]).toContain(p.backoff); + expect(p.baseDelayMs).toBeGreaterThanOrEqual(0); + expect(p.maxDelayMs).toBeGreaterThanOrEqual(p.baseDelayMs); + expect(typeof p.resetFile).toBe("boolean"); + expect(typeof p.switchProvider).toBe("boolean"); + expect(typeof p.refreshLink).toBe("boolean"); + expect(p.providerCooldownMs).toBeGreaterThanOrEqual(0); + } + }); + + it("no unknown keys in RETRY_POLICIES beyond the enum values", () => { + const policyKeys = Object.keys(RETRY_POLICIES); + const enumValues = ALL_KINDS as string[]; + for (const key of policyKeys) { + expect(enumValues, `unexpected key "${key}" in RETRY_POLICIES`).toContain( + key, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2) RetryManager.evaluate() — basic decisions +// --------------------------------------------------------------------------- + +describe("RetryManager.evaluate()", () => { + let mgr: RetryManager; + + beforeEach(() => { + mgr = new RetryManager(); + }); + + it("returns shouldRetry=true on first retryable failure", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d.shouldRetry).toBe(true); + expect(d.delayMs).toBeGreaterThan(0); + expect(d.reason).toContain("1/"); + }); + + it("tracks failure counts per kind", () => { + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + const state = mgr.getState("a")!; + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); + expect(state.totalFailures).toBe(2); + }); + + it("tracks multiple error kinds independently", () => { + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + const state = mgr.getState("a")!; + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); + expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); + expect(state.totalFailures).toBe(3); + }); + + it("stores last error kind and message on state", () => { + mgr.evaluate("x", mkError(DownloadErrorKind.ServerError, "500 oops")); + const state = mgr.getState("x")!; + expect(state.lastErrorKind).toBe(DownloadErrorKind.ServerError); + expect(state.lastErrorMessage).toBe("500 oops"); + }); + + it("keeps separate state per item", () => { + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("b", mkError(DownloadErrorKind.ServerError)); + expect(mgr.getState("a")!.totalFailures).toBe(1); + expect(mgr.getState("b")!.totalFailures).toBe(1); + expect(mgr.getState("a")!.lastErrorKind).toBe(DownloadErrorKind.Timeout); + expect(mgr.getState("b")!.lastErrorKind).toBe(DownloadErrorKind.ServerError); + }); + + it("respects userRetryLimit when set", () => { + const limited = new RetryManager(2); + // Timeout normally has maxRetries=10, but user limit is 2 + const d1 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d1.shouldRetry).toBe(true); + const d2 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d2.shouldRetry).toBe(true); + // Third attempt exceeds limit (kindCount=3 > effectiveMax=2) + const d3 = limited.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d3.shouldRetry).toBe(false); + }); + + it("setRetryLimit updates limit dynamically", () => { + const m = new RetryManager(1); + m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 1/1, ok + const d2 = m.evaluate("a", mkError(DownloadErrorKind.Timeout)); // 2 > 1, fail + expect(d2.shouldRetry).toBe(false); + + // Raise limit; new item should get more room + m.setRetryLimit(5); + const d3 = m.evaluate("b", mkError(DownloadErrorKind.Timeout)); + expect(d3.shouldRetry).toBe(true); + }); + + it("setRetryLimit clamps negative values to 0", () => { + const m = new RetryManager(); + m.setRetryLimit(-5); + // 0 = unlimited, uses policy max + const d = m.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d.shouldRetry).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 3) Exponential backoff — delays increase with attempts +// --------------------------------------------------------------------------- + +describe("exponential backoff", () => { + it("delay increases with attempt count for exponential policies", () => { + const mgr = new RetryManager(); + // Timeout uses exponential backoff with baseDelayMs=200, maxDelayMs=30000 + const delays: number[] = []; + for (let i = 0; i < 5; i++) { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + delays.push(d.delayMs); + } + // With jitter, exact values are nondeterministic, but the trend + // should be non-decreasing (or at worst slightly noisy). + // Check that the 5th delay >= 1st delay (accounting for the 1.5^n growth). + expect(delays[4]).toBeGreaterThanOrEqual(delays[0]); + }); + + it("delay is capped at maxDelayMs", () => { + const mgr = new RetryManager(); + // Use Timeout: maxDelayMs=30_000. After many retries delay should cap. + for (let i = 0; i < 9; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + } + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d.delayMs).toBeLessThanOrEqual(30_000); + }); + + it("fixed backoff returns the same delay every time", () => { + const mgr = new RetryManager(); + // NetworkReset is fixed at 300ms + const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d1.delayMs).toBe(300); + expect(d2.delayMs).toBe(300); + }); + + it("exponential delay is always >= 50% of the capped value", () => { + // computeDelay: max(capped*0.5, capped - jitter) where jitter = capped*random*0.5 + // so result is always >= capped * 0.5 + const mgr = new RetryManager(); + const policy = RETRY_POLICIES[DownloadErrorKind.Timeout]; + for (let i = 0; i < 8; i++) { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + // On attempt i+1, base = 200 * 1.5^i, capped = min(base, 30000) + const base = policy.baseDelayMs * Math.pow(1.5, i); + const capped = Math.min(base, policy.maxDelayMs); + expect(d.delayMs).toBeGreaterThanOrEqual(Math.floor(capped * 0.5)); + expect(d.delayMs).toBeLessThanOrEqual(capped); + } + }); +}); + +// --------------------------------------------------------------------------- +// 4) Max retries — shouldRetry=false after exhausting retries +// --------------------------------------------------------------------------- + +describe("max retries exhaustion", () => { + it("shouldRetry becomes false after maxRetries+1 failures for a retryable kind", () => { + const mgr = new RetryManager(); + const kind = DownloadErrorKind.NetworkReset; // maxRetries=3 + const policy = RETRY_POLICIES[kind]; + + for (let i = 0; i < policy.maxRetries; i++) { + const d = mgr.evaluate("a", mkError(kind)); + expect(d.shouldRetry, `attempt ${i + 1} should be retryable`).toBe(true); + } + // Next failure exceeds limit + const final = mgr.evaluate("a", mkError(kind)); + expect(final.shouldRetry).toBe(false); + expect(final.delayMs).toBe(0); + expect(final.actions).toEqual([]); + expect(final.reason).toContain("erschöpft"); + }); + + it("exhaustion message includes count and max", () => { + const mgr = new RetryManager(); + const kind = DownloadErrorKind.DnsFailure; // maxRetries=2 + failNTimes(mgr, "a", kind, 2); // use up retries + const d = mgr.evaluate("a", mkError(kind)); // 3rd fail + expect(d.shouldRetry).toBe(false); + expect(d.reason).toMatch(/3\/2/); + }); + + it("each kind's retries are tracked independently", () => { + const mgr = new RetryManager(); + // Exhaust NetworkReset (3 retries) + failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); + const d1 = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d1.shouldRetry).toBe(false); + + // Timeout should still be retryable (different kind) + const d2 = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d2.shouldRetry).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 5) Permanent errors — no retry +// --------------------------------------------------------------------------- + +describe("permanent errors", () => { + const permanentKinds: DownloadErrorKind[] = [ + DownloadErrorKind.LinkDead, + DownloadErrorKind.DiskFull, + DownloadErrorKind.PermissionDenied, + DownloadErrorKind.WrongPassword, + ]; + + for (const kind of permanentKinds) { + it(`${kind} is never retried`, () => { + const mgr = new RetryManager(); + const d = mgr.evaluate("a", mkError(kind)); + expect(d.shouldRetry).toBe(false); + expect(d.delayMs).toBe(0); + expect(d.actions).toEqual([]); + }); + } + + it("permanent errors return shouldRetry=false even on first attempt", () => { + const mgr = new RetryManager(); + for (const kind of permanentKinds) { + const d = mgr.evaluate(kind, mkError(kind)); + expect(d.shouldRetry, `${kind} should not retry`).toBe(false); + } + }); + + it("permanent kinds also have maxRetries=0 in their policies", () => { + for (const kind of permanentKinds) { + expect( + RETRY_POLICIES[kind].maxRetries, + `${kind} should have maxRetries=0`, + ).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// 6) Retry actions — correct actions per policy +// --------------------------------------------------------------------------- + +describe("retry actions", () => { + let mgr: RetryManager; + + beforeEach(() => { + mgr = new RetryManager(); + }); + + it("reset_file action for NetworkReset (resetFile=true)", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d.actions).toContain("reset_file"); + }); + + it("no switch_provider for NetworkReset (switchProvider=false)", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d.actions).not.toContain("switch_provider"); + }); + + it("switch_provider action for UnrestrictFailed (switchProvider=true)", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed)); + expect(d.actions).toContain("switch_provider"); + }); + + it("cooldown_provider action for UnrestrictFailed (providerCooldownMs > 0)", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.UnrestrictFailed)); + expect(d.actions).toContain("cooldown_provider"); + }); + + it("refresh_link action for ConnectTimeout (refreshLink=true)", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.ConnectTimeout)); + expect(d.actions).toContain("refresh_link"); + }); + + it("no actions for permanent errors", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead)); + expect(d.actions).toEqual([]); + }); + + it("ProviderBusy yields switch_provider + cooldown_provider", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.ProviderBusy)); + expect(d.actions).toContain("switch_provider"); + expect(d.actions).toContain("cooldown_provider"); + expect(d.actions).not.toContain("reset_file"); + expect(d.actions).not.toContain("refresh_link"); + }); + + it("FileCorrupt yields reset_file + refresh_link", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileCorrupt)); + expect(d.actions).toContain("reset_file"); + expect(d.actions).toContain("refresh_link"); + expect(d.actions).not.toContain("switch_provider"); + }); + + it("FileLocked has no special actions", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.FileLocked)); + expect(d.actions).toEqual([]); + }); + + it("Timeout has no special actions", () => { + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(d.actions).toEqual([]); + }); + + it("actions list matches policy flags for every retryable kind", () => { + for (const kind of ALL_KINDS) { + const policy = RETRY_POLICIES[kind]; + if (policy.maxRetries === 0) continue; // permanent or zero-retry + + const d = mgr.evaluate(`action-check-${kind}`, mkError(kind)); + if (!d.shouldRetry) continue; + + if (policy.resetFile) { + expect(d.actions, `${kind}: missing reset_file`).toContain("reset_file"); + } else { + expect(d.actions, `${kind}: unexpected reset_file`).not.toContain("reset_file"); + } + if (policy.switchProvider) { + expect(d.actions, `${kind}: missing switch_provider`).toContain("switch_provider"); + } else { + expect(d.actions, `${kind}: unexpected switch_provider`).not.toContain("switch_provider"); + } + if (policy.refreshLink) { + expect(d.actions, `${kind}: missing refresh_link`).toContain("refresh_link"); + } else { + expect(d.actions, `${kind}: unexpected refresh_link`).not.toContain("refresh_link"); + } + if (policy.providerCooldownMs > 0) { + expect(d.actions, `${kind}: missing cooldown_provider`).toContain("cooldown_provider"); + } else { + expect(d.actions, `${kind}: unexpected cooldown_provider`).not.toContain("cooldown_provider"); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// 7) Shelving — triggers after SHELVE_THRESHOLD (15) total failures +// --------------------------------------------------------------------------- + +describe("shelving", () => { + const SHELVE_THRESHOLD = 15; + const SHELVE_DELAY_MS = 90_000; + + it("triggers shelving at exactly 15 total failures", () => { + const mgr = new RetryManager(); + // Use a kind with high maxRetries so we don't exhaust it first + const kind = DownloadErrorKind.Timeout; // maxRetries=10 + // Mix in some ServerError too to stay under per-kind limits + for (let i = 0; i < 10; i++) { + mgr.evaluate("a", mkError(kind)); + } + for (let i = 0; i < 4; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + } + // Next one is the 15th failure -> shelve + const d = mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + expect(d.shouldRetry).toBe(true); + expect(d.delayMs).toBe(SHELVE_DELAY_MS); + expect(d.actions).toContain("shelve"); + expect(d.actions).toContain("switch_provider"); + expect(d.actions).toContain("refresh_link"); + }); + + it("shelving halves all kind counters", () => { + const mgr = new RetryManager(); + for (let i = 0; i < 10; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + } + for (let i = 0; i < 4; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + } + // 15th failure -> shelve + mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + const state = mgr.getState("a")!; + // After halving: Timeout 10->5, ServerError 5->2, total=7 + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(5); + expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(2); + expect(state.totalFailures).toBe(7); + expect(state.shelveCount).toBe(1); + }); + + it("shelving increments shelveCount", () => { + const mgr = new RetryManager(); + // Trigger shelve twice + // First round: 15 failures -> shelve (halves to ~7) + for (let i = 0; i < 15; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + } + const state1 = mgr.getState("a")!; + expect(state1.shelveCount).toBe(1); + + // After halving, totalFailures is ~7. Need 8 more to reach 15 again. + const remaining = SHELVE_THRESHOLD - state1.totalFailures; + for (let i = 0; i < remaining; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + } + const state2 = mgr.getState("a")!; + expect(state2.shelveCount).toBe(2); + }); + + it("shelve decision always has shouldRetry=true", () => { + const mgr = new RetryManager(); + for (let i = 0; i < 15; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + } + // The 15th call itself triggers shelve + // Let's re-check: the state now has halved counters. + // One more batch to trigger shelve again + const state = mgr.getState("a")!; + const needed = SHELVE_THRESHOLD - state.totalFailures; + for (let i = 0; i < needed - 1; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + } + const d = mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + expect(d.shouldRetry).toBe(true); + expect(d.delayMs).toBe(SHELVE_DELAY_MS); + }); + + it("shelve is checked before per-kind exhaustion", () => { + const mgr = new RetryManager(); + // NetworkReset has maxRetries=3. If we mix kinds to reach 15 total + // without exhausting any single kind, shelve takes priority. + // Use 5 kinds, 3 each = 15 + const kinds = [ + DownloadErrorKind.Timeout, + DownloadErrorKind.ServerError, + DownloadErrorKind.RateLimited, + DownloadErrorKind.Unknown, + DownloadErrorKind.WriteDrainTimeout, + ]; + for (let i = 0; i < 14; i++) { + mgr.evaluate("a", mkError(kinds[i % kinds.length])); + } + // 15th failure -> shelve (not per-kind exhaustion) + const d = mgr.evaluate("a", mkError(kinds[4])); + expect(d.actions).toContain("shelve"); + expect(d.shouldRetry).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 8) resetItem() — clears retry state +// --------------------------------------------------------------------------- + +describe("resetItem()", () => { + it("removes all state for the given item", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + expect(mgr.getState("a")).toBeDefined(); + + mgr.resetItem("a"); + expect(mgr.getState("a")).toBeUndefined(); + }); + + it("after reset, the item starts fresh", () => { + const mgr = new RetryManager(); + // Accumulate some failures + failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); + mgr.resetItem("a"); + + // First failure after reset should be attempt 1 + const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d.shouldRetry).toBe(true); + expect(d.reason).toContain("1/"); + }); + + it("resetting one item does not affect other items", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("b", mkError(DownloadErrorKind.Timeout)); + + mgr.resetItem("a"); + expect(mgr.getState("a")).toBeUndefined(); + expect(mgr.getState("b")).toBeDefined(); + expect(mgr.getState("b")!.totalFailures).toBe(1); + }); + + it("resetting a non-existent item is a no-op", () => { + const mgr = new RetryManager(); + // Should not throw + expect(() => mgr.resetItem("nonexistent")).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// 9) softReset() — halves counters +// --------------------------------------------------------------------------- + +describe("softReset()", () => { + it("halves failure counts for all items", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8); + failNTimes(mgr, "b", DownloadErrorKind.ServerError, 6); + + mgr.softReset(); + + const stateA = mgr.getState("a")!; + expect(stateA.failuresByKind[DownloadErrorKind.Timeout]).toBe(4); + expect(stateA.totalFailures).toBe(4); + + const stateB = mgr.getState("b")!; + expect(stateB.failuresByKind[DownloadErrorKind.ServerError]).toBe(3); + expect(stateB.totalFailures).toBe(3); + }); + + it("uses floor division (odd counts lose the remainder)", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.Timeout, 5); + + mgr.softReset(); + const state = mgr.getState("a")!; + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); // floor(5/2) + expect(state.totalFailures).toBe(2); + }); + + it("totalFailures is recalculated from individual kind counts", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.Timeout, 7); + failNTimes(mgr, "a", DownloadErrorKind.ServerError, 3); + // total = 10 + + mgr.softReset(); + const state = mgr.getState("a")!; + // Timeout: floor(7/2) = 3, ServerError: floor(3/2) = 1 => total = 4 + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3); + expect(state.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); + expect(state.totalFailures).toBe(4); + }); + + it("double softReset keeps halving", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.Timeout, 8); + + mgr.softReset(); // 8 -> 4 + mgr.softReset(); // 4 -> 2 + const state = mgr.getState("a")!; + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(2); + expect(state.totalFailures).toBe(2); + }); + + it("softReset on zero-failure items is a no-op", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.LinkDead)); // permanent, but state exists + const stateBefore = { ...mgr.getState("a")! }; + + // totalFailures is 1, so softReset will halve it + mgr.softReset(); + const stateAfter = mgr.getState("a")!; + // floor(1/2) = 0 + expect(stateAfter.totalFailures).toBe(0); + }); + + it("softReset does not remove items from the map", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.Timeout, 2); + + mgr.softReset(); + expect(mgr.getState("a")).toBeDefined(); + }); + + it("softReset allows previously exhausted kinds to retry", () => { + const mgr = new RetryManager(); + // NetworkReset maxRetries=3. Exhaust it. + failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 3); + const exhausted = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(exhausted.shouldRetry).toBe(false); + + // softReset: kindCount 4 -> 2, total 4 -> 2 + mgr.softReset(); + // Now kindCount=2, effectiveMax=3, so 2 <= 3 → retry + const recovered = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(recovered.shouldRetry).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 10) State export/import — roundtrip +// --------------------------------------------------------------------------- + +describe("exportStates() and importStates()", () => { + it("roundtrips state faithfully", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("a", mkError(DownloadErrorKind.ServerError)); + mgr.evaluate("b", mkError(DownloadErrorKind.NetworkReset)); + + const exported = mgr.exportStates(); + + const mgr2 = new RetryManager(); + mgr2.importStates(exported); + + expect(mgr2.getState("a")!.totalFailures).toBe(2); + expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.Timeout]).toBe(1); + expect(mgr2.getState("a")!.failuresByKind[DownloadErrorKind.ServerError]).toBe(1); + expect(mgr2.getState("b")!.totalFailures).toBe(1); + expect(mgr2.getState("b")!.failuresByKind[DownloadErrorKind.NetworkReset]).toBe(1); + }); + + it("exported states are deep copies (no shared references)", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + + const exported = mgr.exportStates(); + // Mutate the export + exported["a"].totalFailures = 999; + exported["a"].failuresByKind[DownloadErrorKind.Timeout] = 999; + + // Original should be unaffected + const state = mgr.getState("a")!; + expect(state.totalFailures).toBe(1); + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(1); + }); + + it("importStates clears previous state", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("b", mkError(DownloadErrorKind.ServerError)); + + // Import only "c" + mgr.importStates({ + c: { + failuresByKind: { [DownloadErrorKind.DnsFailure]: 1 }, + totalFailures: 1, + shelveCount: 0, + }, + }); + + expect(mgr.getState("a")).toBeUndefined(); + expect(mgr.getState("b")).toBeUndefined(); + expect(mgr.getState("c")).toBeDefined(); + expect(mgr.getState("c")!.totalFailures).toBe(1); + }); + + it("importStates deep-copies input (no shared references)", () => { + const mgr = new RetryManager(); + const input: Record = { + x: { + failuresByKind: { [DownloadErrorKind.Timeout]: 3 }, + totalFailures: 3, + shelveCount: 0, + }, + }; + + mgr.importStates(input); + // Mutate the input after import + input.x.totalFailures = 999; + input.x.failuresByKind[DownloadErrorKind.Timeout] = 999; + + const state = mgr.getState("x")!; + expect(state.totalFailures).toBe(3); + expect(state.failuresByKind[DownloadErrorKind.Timeout]).toBe(3); + }); + + it("empty export for fresh manager", () => { + const mgr = new RetryManager(); + const exported = mgr.exportStates(); + expect(exported).toEqual({}); + }); + + it("import empty object clears all state", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.importStates({}); + expect(mgr.getState("a")).toBeUndefined(); + expect(mgr.exportStates()).toEqual({}); + }); + + it("shelveCount survives export/import roundtrip", () => { + const mgr = new RetryManager(); + // Trigger shelve + for (let i = 0; i < 15; i++) { + mgr.evaluate("a", mkError(DownloadErrorKind.Unknown)); + } + const originalShelve = mgr.getState("a")!.shelveCount; + expect(originalShelve).toBeGreaterThan(0); + + const exported = mgr.exportStates(); + const mgr2 = new RetryManager(); + mgr2.importStates(exported); + expect(mgr2.getState("a")!.shelveCount).toBe(originalShelve); + }); + + it("continued evaluation works after import", () => { + const mgr = new RetryManager(); + failNTimes(mgr, "a", DownloadErrorKind.NetworkReset, 2); + + const exported = mgr.exportStates(); + const mgr2 = new RetryManager(); + mgr2.importStates(exported); + + // 3rd attempt (maxRetries=3) should still be retryable + const d = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d.shouldRetry).toBe(true); + + // 4th attempt exceeds limit + const d2 = mgr2.evaluate("a", mkError(DownloadErrorKind.NetworkReset)); + expect(d2.shouldRetry).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// restoreState() and removeItem() +// --------------------------------------------------------------------------- + +describe("restoreState()", () => { + it("restores a single item's state", () => { + const mgr = new RetryManager(); + mgr.restoreState("x", { + failuresByKind: { [DownloadErrorKind.Timeout]: 5 }, + totalFailures: 5, + shelveCount: 1, + lastErrorKind: DownloadErrorKind.Timeout, + lastErrorMessage: "stalled", + }); + + const state = mgr.getState("x")!; + expect(state.totalFailures).toBe(5); + expect(state.shelveCount).toBe(1); + expect(state.lastErrorKind).toBe(DownloadErrorKind.Timeout); + }); + + it("restoreState does not affect other items", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.restoreState("b", { + failuresByKind: {}, + totalFailures: 0, + shelveCount: 0, + }); + + expect(mgr.getState("a")!.totalFailures).toBe(1); + expect(mgr.getState("b")!.totalFailures).toBe(0); + }); +}); + +describe("removeItem()", () => { + it("removes state for a specific item", () => { + const mgr = new RetryManager(); + mgr.evaluate("a", mkError(DownloadErrorKind.Timeout)); + mgr.evaluate("b", mkError(DownloadErrorKind.Timeout)); + + mgr.removeItem("a"); + expect(mgr.getState("a")).toBeUndefined(); + expect(mgr.getState("b")).toBeDefined(); + }); + + it("removing non-existent item is a no-op", () => { + const mgr = new RetryManager(); + expect(() => mgr.removeItem("nope")).not.toThrow(); + }); +});