feat: Download System v2 — complete rewrite of download pipeline

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 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-08 18:14:17 +01:00
parent 63b412a43f
commit efa0909e11
14 changed files with 6970 additions and 2 deletions

View File

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

View File

@ -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, RetryPolicy> = {
[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<Record<DownloadErrorKind, number>>;
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<string, RetryState> = 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<string, RetryState>;
/**
* Import states from persistence.
*/
importStates(states: Record<string, RetryState>): 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<PipelineResult>
```
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<string, ArchiveExtractionState>;
startedAt: number;
completedAt?: number;
}
```
**Step 2: Implement PostProcessor class**
```typescript
export class PostProcessor extends EventEmitter {
private states: Map<string, PackagePostProcessState> = new Map();
private abortControllers: Map<string, AbortController> = 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<void>;
/**
* 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<string, { packageId: string; heartbeatAt: number; bytesAtHeartbeat: number }> = new Map();
// Provider cooldowns (circuit breaker)
private providerCooldowns: Map<string, ProviderCooldown> = new Map();
// Retry delays per item
private retryDelays: Map<string, number> = new Map(); // itemId → retryAfterEpochMs
constructor(config: SchedulerConfig);
/**
* Start the scheduler loop.
*/
async start(findNextItem: () => SlotRequest | null, startItem: (slot: SlotRequest) => void): Promise<void>;
/**
* 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"
```

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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>([
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<string, unknown>;
constructor(
kind: DownloadErrorKind,
message: string,
opts?: {
httpStatus?: number;
originalError?: Error;
retryable?: boolean;
permanent?: boolean;
context?: Record<string, unknown>;
},
) {
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, string> = {
[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);
}

View File

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

View File

@ -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<UnrestrictedLink>;
}
/** 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<string, unknown>) => 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<PipelineResult> {
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<string, string> = {
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;
}

View File

@ -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<string, ArchiveExtractionState>;
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<string, PackagePostProcessState>();
private abortControllers = new Map<string, AbortController>();
private activeTasks = new Map<string, Promise<void>>();
private activeSlots = 0;
private maxSlots: number;
private slotWaiters: Array<() => void> = [];
/** Extraction function — injected to avoid circular dependency. */
private extractFn: ((opts: any) => Promise<any>) | null = null;
/** Archive candidate finder. */
private findArchivesFn: ((dir: string) => string[] | Promise<string[]>) | null = null;
constructor(maxParallel: number = 2) {
super();
this.maxSlots = maxParallel;
}
/** Inject the extraction function (from extractor.ts). */
setExtractor(
extractFn: (opts: any) => Promise<any>,
findArchivesFn: (dir: string) => string[] | Promise<string[]>,
): 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<void> {
await Promise.allSettled([...this.activeTasks.values()]);
}
// -----------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------
private async runPostProcessing(packageId: string, options: PostProcessOptions): Promise<void> {
// 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<void> {
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<void> {
while (this.activeSlots >= this.maxSlots) {
if (signal.aborted) return;
await new Promise<void>(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();
}
}

View File

@ -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<DownloadErrorKind, RetryPolicy> = {
// -- 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<Record<DownloadErrorKind, number>>;
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<string, RetryState>();
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<string, RetryState> {
const out: Record<string, RetryState> = {};
for (const [id, state] of this.states) {
out[id] = { ...state, failuresByKind: { ...state.failuresByKind } };
}
return out;
}
/**
* Import retry states from persistence.
*/
importStates(states: Record<string, RetryState>): 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;
}
}

View File

@ -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<string, ActiveSlot>();
// Retry delays
private retryDelays = new Map<string, number>(); // itemId → readyAtEpochMs
// Provider cooldowns
private providerCooldowns = new Map<string, { cooldownUntil: number; failureCount: number }>();
// Reconnect state
private reconnectUntil = 0;
// Global watchdog state
private lastGlobalProgressBytes = 0;
private lastGlobalProgressAt = 0;
// Scoped run (only these packages)
private scopedPackageIds = new Set<string>();
constructor(config: SchedulerConfig) {
super();
this.config = { ...config };
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/** Update config at runtime (e.g. when user changes maxParallel). */
updateConfig(partial: Partial<SchedulerConfig>): 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<void> {
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<string, ActiveSlot> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -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<string, unknown>) => 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<StreamResult> {
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<string, string> = {};
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<void> => 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<void> => {
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<ReadableStreamReadResult<Uint8Array>> => {
if (stallTimeoutMs <= 0) return reader.read();
return new Promise<ReadableStreamReadResult<Uint8Array>>((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<void>((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<string, unknown>) => void,
): Promise<StreamResult | null> {
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<void> {
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 ?? "");
}

View File

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

812
tests/retry-manager.test.ts Normal file
View File

@ -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<string, RetryState> = {
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();
});
});