Downloader 5.1.0

dotnet add package Downloader --version 5.1.0
                    
NuGet\Install-Package Downloader -Version 5.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Downloader" Version="5.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Downloader" Version="5.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Downloader" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Downloader --version 5.1.0
                    
#r "nuget: Downloader, 5.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Downloader@5.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Downloader&version=5.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Downloader&version=5.1.0
                    
Install as a Cake Tool

Windows x64 Ubuntu x64 MacOS codecov NuGet NuGet CodeFactor License Generic badge Generic badge Generic badge Generic badge

Downloader

🚀 Fast, cross-platform, and reliable multipart downloader in .Net 🚀

Downloader is a modern, fluent, asynchronous, and portable library for .NET, built with testability in mind. It supports multipart downloads with real-time asynchronous progress events. The library is compatible with projects targeting .NET Standard 2.1, .NET 8, and later versions.

Downloader works on Windows, Linux, and macOS.

Note: Support for older versions of .NET was removed in Downloader v3.2.0. From this version onwards, only .Net 8.0 and higher versions are supported.
If you need compatibility with older .NET versions (e.g., .NET Framework 4.6.1), use Downloader v3.1.*.

For a complete example, see the Downloader.Sample project in this repository.

Sample Console Application

sample-project


Key Features

  • Simple interface for download requests.
  • Asynchronous, non-blocking file downloads.
  • Supports all file types (e.g., images, videos, PDFs, APKs).
  • Cross-platform support for files of any size.
  • Real-time progress updates for each download chunk.
  • Downloads files in multiple parts (parallel download).
  • Resilient to client-side and server-side errors.
  • Configurable ChunkCount to control download segmentation.
  • Supports both in-memory and on-disk multipart downloads.
  • Parallel saving of chunks directly into the final file (no temporary files).
  • Always downloads to a temporary file (configurable extension, default .download), then renames to the final name on completion.
  • Always pre-allocates file size before download begins.
  • Resume downloads manually by saving and restoring the DownloadPackage object.
  • Automatic resume: when enabled, download metadata is embedded inside the .download file — no extra files or manual serialization needed.
  • Provides real-time speed and progress data.
  • Asynchronous pause and resume functionality.
  • Download files with dynamic speed limits.
  • Supports downloading to memory streams (without saving to disk).
  • Supports large file downloads and live-streaming (e.g., music playback during download).
  • Download a specific byte range from a large file.
  • Lightweight, fast codebase with no external dependencies.
  • Manage RAM usage during downloads.
  • Supports custom HttpClient or HttpMessageHandler injection for advanced scenarios (e.g., IHttpClientFactory, HTTP caching, custom delegating handlers).

Installation via NuGet

PM> Install-Package Downloader

Installation via the .NET CLI

dotnet add package Downloader

Usage

Step 1: Create a Custom Configuration

Simple Configuration
var downloadOpt = new DownloadConfiguration()
{
    ChunkCount = 8, // Number of file parts, default is 1
    ParallelDownload = true // Download parts in parallel (default is false)
};

Complex Configuration

Note: Only include the options you need in your application.

var downloadOpt = new DownloadConfiguration()
{
    // usually, hosts support max to 8000 bytes, default value is 8000
    BufferBlockSize = 10240, // 10KB
    // file parts to download, the default value is 1
    ChunkCount = 8,             
    // download speed limited to MaximumBytesPerSecond, default values is zero or unlimited
    MaximumBytesPerSecond = 1024*1024*2, // 2MB/s
    // the maximum number of times to fail
    MaxTryAgainOnFailure = 5,    
    // release memory buffer after each MaximumMemoryBufferBytes 
    MaximumMemoryBufferBytes = 1024 * 1024 * 50, // 50MB
    // download parts of the file as parallel or not. The default value is false
    ParallelDownload = true,
    // number of parallel downloads. The default value is the same as the chunk count
    ParallelCount = 4,    
    // timeout (millisecond) per stream block reader, default values is 1000
    BlockTimeout = 1000,
    // timeout (millisecond) per HttpClientRequest, default values is 100 Seconds
    HTTPClientTimeout = 100 * 1000,
    // set true if you want to download just a specific range of bytes of a large file
    RangeDownload = false,
    // floor offset of download range of a large file
    RangeLow = 0,
    // ceiling offset of download range of a large file
    RangeHigh = 0, 
    // clear package chunks data when download completed with failure, default value is false
    ClearPackageOnCompletionWithFailure = true, 
    // the minimum size of file to chunking or download a file in multiple parts, the default value is 512
    MinimumSizeOfChunking = 102400, // 100KB
    // the minimum size of a single chunk, default value is 0 equal unlimited
    MinimumChunkSize = 10240, // 10KB
    // Get on demand downloaded data with ReceivedBytes on downloadProgressChanged event 
    EnableLiveStreaming = false,
    // How to handle existing filename when starting to download?
    FileExistPolicy = FileExistPolicy.Delete,
    // When enabled, the Downloader appends package metadata to the end of the
    // .download file. On the next download attempt, if metadata is found in an
    // existing .download file, the download resumes automatically.
    EnableAutoResumeDownload = true,
    // A temporary extension appended to the real filename while downloading.
    // e.g., "file.zip" becomes "file.zip.download" during download.
    // The Downloader always uses this extension regardless of EnableAutoResumeDownload.
    // When the download completes, the file is renamed back to its final name.
    DownloadFileExtension = ".download",
    // config and customize request headers
    RequestConfiguration = 
    {        
        Accept = "*/*",
        CookieContainer = cookies,
        Headers = ["Accept-Encoding: gzip, deflate, br"], // { your custom headers }
        KeepAlive = true, // default value is false
        ProtocolVersion = HttpVersion.Version11, // default value is HTTP 1.1
        // your custom user agent or your_app_name/app_version.
        UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
        Proxy = new WebProxy() {
           Address = new Uri("http://YourProxyServer/proxy.pac"),
           UseDefaultCredentials = false,
           Credentials = System.Net.CredentialCache.DefaultNetworkCredentials,
           BypassProxyOnLocal = true
        },
        Authorization = new AuthenticationHeaderValue("Bearer", "token");
    }
};

Step 2: Create the Download Service

var downloader = new DownloadService(downloadOpt);

Step 3: Handle Download Events

// Provide `FileName` and `TotalBytesToReceive` at the start of each download
downloader.DownloadStarted += OnDownloadStarted;

// Provide any information about chunker downloads, 
// like progress percentage per chunk, speed, 
// total received bytes and received bytes array to live streaming.
downloader.ChunkDownloadProgressChanged += OnChunkDownloadProgressChanged;

// Provide any information about download progress, 
// like progress percentage of sum of chunks, total speed, 
// average speed, total received bytes and received bytes array 
// to live streaming.
downloader.DownloadProgressChanged += OnDownloadProgressChanged;

// Download completed event that can include errors or 
// canceled or download completed successfully.
downloader.DownloadFileCompleted += OnDownloadFileCompleted;

Step 4: Start the Download

string file = @"Your_Path\fileName.zip";
string url = @"https://file-examples.com/fileName.zip";
await downloader.DownloadFileTaskAsync(url, file);

Step 4b: Start the download without file name

DirectoryInfo path = new DirectoryInfo("Your_Path");
string url = @"https://file-examples.com/fileName.zip";
// download into "Your_Path\fileName.zip"
await downloader.DownloadFileTaskAsync(url, path); 

Step 4c: Download in MemoryStream

// After download completion, it gets a MemoryStream
Stream destinationStream = await downloader.DownloadFileTaskAsync(url); 

How to pause and resume downloads quickly

When you want to resume a download quickly after pausing a few seconds. You can call the Pause function of the downloader service. This way, streams stay alive and are only suspended by a locker to be released and resumed whenever you want.

// Pause the download
DownloadService.Pause();

// Resume the download
DownloadService.Resume();

How to stop and resume downloads (manual approach)

The DownloadService class has a property called Package that holds a live snapshot of the download state (chunk positions, URL, file path, etc.). While the download is in progress, this object is updated continuously.

To stop and later resume a download manually, you are responsible for keeping the Package object yourself — either in memory or serialized to disk. The Downloader does not store it for you in this approach.

// 1. Keep a reference to the package before or after stopping:
DownloadPackage pack = downloader.Package;

Stop or cancel the download:

await downloader.CancelAsync();

Resume later — even after restarting the application:

// Pass the same (or deserialized) package to resume from the last position:
await downloader.DownloadFileTaskAsync(pack);

The Package object is lightweight — it contains only the URL, file path, and the position of each chunk (not the downloaded bytes). You can serialize it to JSON or binary (see Serialization section) and restore it at any time.

For more details see the StopResumeDownloadTest method.

Note: If the server does not support HTTP range requests, the download cannot be resumed and will restart from the beginning.


If you don't want to manage DownloadPackage serialization yourself, enable EnableAutoResumeDownload. The Downloader will then handle everything automatically — no manual serialization or package management is needed.

Configuration:

var downloadOpt = new DownloadConfiguration()
{
    EnableAutoResumeDownload = true,
    DownloadFileExtension = ".download" // optional, this is the default
};

var downloader = new DownloadService(downloadOpt);

How .download files work:

The Downloader always uses a temporary file with the .download extension (configurable via DownloadFileExtension) — this behavior is independent of EnableAutoResumeDownload. For example, downloading report.pdf creates report.pdf.download on disk. While the download is in progress, only the .download file exists — the user does not see the final filename. When the download completes successfully, the file is renamed to report.pdf.

What EnableAutoResumeDownload adds:

When this option is true, the Downloader appends package metadata to the end of the .download file, immediately after the file data. This metadata enables automatic resume without any work from the caller.

The file structure during download looks like this:

report.pdf.download:
|<---------- File Data (TotalFileSize) ----------><-- Metadata -->|
  • File Data region: The actual file content. The file size for this region is pre-allocated at the start.
  • Metadata region: The DownloadPackage state (includes TotalFileSize and Chunks) is serialized to JSON and then written as binary at the end of the same file.

The metadata grows as the download progresses (more chunk positions are recorded), so the on-disk file size is always TotalFileSize + current metadata size. The metadata only grows — it never shrinks — so no padding or extra management is needed.

On interruption:

If the download is interrupted (crash, network failure, app restart), the .download file remains on disk with both the partial file data and the latest metadata embedded at the end.

On resume:

When you call DownloadFileTaskAsync for the same file again, the Downloader:

  1. Detects the existing .download file
  2. Reads the metadata from the end of the file to restore the DownloadPackage state
  3. Verifies the server still supports range requests and that the file size has not changed
  4. Resumes downloading from where each chunk left off
  5. Falls back to a fresh download if any validation fails

On completion:

When the download finishes successfully:

  1. The file stream is truncated to TotalFileSize using SetLength(TotalFileSize), which removes the appended metadata
  2. The file is renamed from report.pdf.download to report.pdf

The result is a clean final file with no metadata artifacts.

Note: If the server does not support range requests or the remote file size has changed, the Downloader will discard the partial data and start a fresh download.


Fluent download builder usage

For easy and fluent use of the downloader, you can use the DownloadBuilder class. Consider the following examples:

Simple usage:

await DownloadBuilder.New()
    .WithUrl(@"https://host.com/test-file.zip")
    .WithDirectory(@"C:\temp")
    .Build()
    .StartAsync();

Complex usage:

IDownload download = DownloadBuilder.New()
    .WithUrl(@"https://host.com/test-file.zip")
    .WithDirectory(@"C:\temp")
    .WithFileName("test-file.zip")
    .WithConfiguration(new DownloadConfiguration())
    .Build();

download.DownloadProgressChanged += DownloadProgressChanged;
download.DownloadFileCompleted += DownloadFileCompleted;
download.DownloadStarted += DownloadStarted;
download.ChunkDownloadProgressChanged += ChunkDownloadProgressChanged;

await download.StartAsync();

download.Stop(); // cancel current download

Resume the existing download package:

await DownloadBuilder.Build(package).StartAsync();

Resume the existing download package with a new configuration:

await DownloadBuilder.Build(package, config).StartAsync();

Pause and Resume quickly:

var download = DownloadBuilder.New()
     .Build()
     .WithUrl(url)
     .WithFileLocation(path);
await download.StartAsync();

download.Pause(); // pause current download quickly

download.Resume(); // continue current download quickly

Using a Custom HttpClient or HttpMessageHandler

Some scenarios require using a custom HttpClient or HttpMessageHandler — for example, to reuse the connection pool from IHttpClientFactory, add HTTP caching via a DelegatingHandler, or apply custom authentication logic.

The Downloader provides two delegate properties on DownloadConfiguration for this purpose:

Option 1: Provide a fully custom HttpClient

Use CustomHttpClientFactory when you want full control over the HttpClient instance. The Downloader will skip all internal handler and header configuration and use the returned client directly.

var downloadOpt = new DownloadConfiguration()
{
    ChunkCount = 8,
    ParallelDownload = true,
    CustomHttpClientFactory = () => {
        // Example: use IHttpClientFactory
        return httpClientFactory.CreateClient("MyDownloader");
    }
};

var downloader = new DownloadService(downloadOpt);
await downloader.DownloadFileTaskAsync(url, filePath);

Option 2: Provide a custom HttpMessageHandler

Use CustomHttpMessageHandlerFactory when you want to customize only the handler (e.g., for caching or custom SSL), while letting the Downloader still configure default request headers and timeout on the HttpClient.

var downloadOpt = new DownloadConfiguration()
{
    ChunkCount = 8,
    ParallelDownload = true,
    CustomHttpMessageHandlerFactory = () => {
        return new SocketsHttpHandler {
            MaxConnectionsPerServer = 500,
            PooledConnectionLifetime = TimeSpan.FromMinutes(10)
        };
    }
};

var downloader = new DownloadService(downloadOpt);
await downloader.DownloadFileTaskAsync(url, filePath);

Using the fluent builder API

Both options are also available through the DownloadBuilder:

// With a custom HttpClient
await DownloadBuilder.New()
    .WithUrl(url)
    .WithDirectory(@"C:\temp")
    .WithHttpClient(() => httpClientFactory.CreateClient("MyDownloader"))
    .Build()
    .StartAsync();

// With a custom HttpMessageHandler
await DownloadBuilder.New()
    .WithUrl(url)
    .WithDirectory(@"C:\temp")
    .WithHttpMessageHandler(() => new SocketsHttpHandler {
        MaxConnectionsPerServer = 500,
        PooledConnectionLifetime = TimeSpan.FromMinutes(10)
    })
    .Build()
    .StartAsync();

Note: If both CustomHttpClientFactory and CustomHttpMessageHandlerFactory are set, CustomHttpClientFactory takes precedence and CustomHttpMessageHandlerFactory is ignored.


When does the Downloader fail to download in multiple chunks?

Content-Length

If your URL server does not provide the file size in the response header (Content-Length). The Downloader cannot split the file into multiple parts and continues its work with one chunk.

Accept-Ranges

If the server returns Accept-Ranges: none in the responses header then that means the server does not support download in range and the Downloader cannot use multiple chunking and continues its work with one chunk.

Content-Range

At first, the Downloader sends a GET request to the server to fetch the file's size in the range. If the server does not provide Content-Range in the header then that means the server does not support download in range. Therefore, the Downloader has to continue its work with one chunk.


How to serialize and deserialize the downloader package

Tip: If you use EnableAutoResumeDownload = true, you do not need to serialize the package yourself — the Downloader handles it automatically by embedding metadata in the .download file. The section below is only relevant if you use the manual resume approach.

The DownloadPackage object holds the download state (URL, file path, chunk positions). You can serialize it to JSON or binary so that you can restore and resume a stopped download later — even after the application restarts.

JSON Serialization

// Serialize
var packageJson = JsonConvert.SerializeObject(package);

// Deserialize
var restoredPack = JsonConvert.DeserializeObject<DownloadPackage>(packageJson);

// Resume
await downloader.DownloadFileTaskAsync(restoredPack);

For more details see the PackageSerializationTest method.

Binary Serialization

To save the package as a binary file, serialize it to JSON first and then write it with BinaryWriter.

NOTE: Do not use BinaryFormatter — it is deprecated and insecure. Reference

🚀 Building a Native AOT Version

This project supports Ahead-of-Time (AOT) compilation, which generates a standalone native executable with faster startup times and reduced memory usage. Follow these steps to build the AOT version.


Prerequisites

  • .NET 8.0 SDK or later.
  • A supported platform (Windows, Linux, or macOS).

Build Instructions

1. Clone the Repository
git clone https://github.com/bezzad/downloader.git
cd downloader
2. Build the Native AOT Executable

Run the following command to compile the project for your target platform:

Windows (x64):

dotnet publish -r win-x64 -f net8.0 -c Release

Linux (x64):

dotnet publish -r linux-x64 -f net8.0 -c Release

macOS (x64):

dotnet publish -r osx-x64 -f net8.0 -c Release
3. Find the Output

The compiled executable will be located in:

bin/Release/net8.0/<RUNTIME_IDENTIFIER>/publish/

Example for Windows:

bin/Release/net8.0/win-x64/publish/

Instructions for Contributing

Welcome to contribute, feel free to change and open a PullRequest to develop the branch. You can use either the latest version of Visual Studio or Visual Studio Code and .NET CLI for Windows, Mac and Linux.

For GitHub workflow, check out our Git workflow below this paragraph. We are following the excellent GitHub Flow process, and would like to make sure you have all the information needed to be a world-class contributor!

Git Workflow

The general process for working with Downloader is:

  1. Fork on GitHub
  2. Make sure your line endings are correctly configured and fix your line endings!
  3. Clone your fork locally
  4. Configure the upstream repo (git remote add upstream git://github.com/bezzad/downloader)
  5. Switch to the latest development branch (e.g. vX.Y.Z, using git checkout vX.Y.Z)
  6. Create a local branch from that (git checkout -b myBranch).
  7. Work on your feature
  8. Rebase if required
  9. Push the branch up to GitHub (git push origin myBranch)
  10. Send a Pull Request on GitHub - the PR should target (have as a base branch) the latest development branch (eg vX.Y.Z) rather than master.

We accept pull requests from the community. But, you should never work on a clone of the master, and you should never send a pull request from the master - always from a branch. Please be sure to branch from the head of the latest vX.Y.Z develop branch (rather than master) when developing contributions.

You can run tests with the Docker Compose file with the following command

docker-compose -p downloader up

Or with docker file

docker build -f ./dockerfile -t downloader-linux . docker run --name downloader-linux-container -d downloader-linux --env=ASPNETCORE_ENVIRONMENT=Development .

Or run the following command to call docker directly

docker run --rm -v ${pwd}:/app --env=ASPNETCORE_ENVIRONMENT=Development -w /app/tests mcr.microsoft.com/dotnet/sdk:6.0 dotnet test ../ --logger:trx

License

Licensed under the terms of the MIT License

FOSSA Status

Contributors

Thanks go to these wonderful people (List made with contrib. rocks):

<a href="https://github.com/bezzad/downloader/graphs/contributors"> <img alt="downloader contributors" src="https://contrib.rocks/image?repo=bezzad/downloader" /> </a>

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (13)

Showing the top 5 NuGet packages that depend on Downloader:

Package Downloads
SquareMinecraftLauncher.Core

BaiBaoStudio

Orobouros

A fully-featured and modular online scraper tool. Yes we know the name is spelled wrong. Icon Credit: Hyliian @ DeviantArt

OverrideLauncher.Core

新一代 MC 启动器,迅速、快速、轻便!

EasyUpdate

Easy Update 提供简单的自动更新服务。

SmartUtilTools

提供的高效工具集,包含加密、JSON处理、时区转换等功能。

GitHub repositories (24)

Showing the top 20 popular GitHub repositories that depend on Downloader:

Repository Stars
2dust/v2rayN
A GUI client for Windows, Linux and macOS, support Xray and sing-box and others
yaobiao131/downkyicore
哔哩下载姬(跨平台版)downkyi,哔哩哔哩网站视频下载工具,支持批量下载,支持8K、HDR、杜比视界,提供工具箱(音视频提取、去水印等)。
goatcorp/FFXIVQuickLauncher
Custom launcher for FFXIV
ClassIsland/ClassIsland
一款功能强、可定制、跨平台,适用于班级多媒体屏幕的课表信息显示工具,可以一目了然地显示各种信息。
Paving-Base/APK-Installer
An Android Application Installer for Windows
CHKZL/DDTV
可对阿B进行直播多窗口观看、开播提醒、自动录制、合并、转码的跨平台工具
rogerfar/rdt-client
Real-Debrid Client Proxy
Taiizor/Sucrose
Sucrose is a versatile wallpaper engine that brings life to your desktop with a wide range of interactive wallpapers.
jxlpzqc/TMSpeech
腾讯会议摸鱼工具
chickensoft-games/GodotEnv
Manage Godot versions and addons from the command line on Windows, macOS, and Linux.
K12f/BlueCatKoKo
蓝猫KoKo下载器(BlueCatKoKo)是一个免登录,简单易用的桌面端抖音,快手视频下载工具,具有简洁的界面,流畅的操作逻辑。可以下载几乎所有的视频,并输出mp4格式的文件。
HandyOrg/HandyWinGet
GUI for installing apps through WinGet and Creating Yaml file
Ameliorated-LLC/trusted-uninstaller-cli
Core functionality for AME Wizard
LocalizeLimbusCompany/LLC_MOD_Toolbox
模组安装程序
YuukiPS/Launcher-PC
Team-Resurgent/Repackinator
yiikooo/Aurelio
Euterpe-org/Euterpe
Muse Dash Mod Manager
baibao132/SquareMinecraftLauncherCore
ktxiaok/FireAxe
A Left 4 Dead 2 addon manager that supports hierarchical organization, workshop items and collections download, addon enablement management, etc.
Version Downloads Last Updated
5.1.0 95 3/11/2026
5.0.0 227 3/10/2026
4.1.1 6,972 2/10/2026
4.1.0 1,449 2/9/2026
4.0.3 91,535 8/9/2025
4.0.2 6,833 7/12/2025
4.0.1-beta 823 5/14/2025
4.0.0-beta 303 5/14/2025
3.3.4 58,073 3/10/2025
3.3.3 29,523 1/13/2025
3.3.2 404 1/13/2025
3.3.1 18,541 11/28/2024
3.3.0 3,447 11/20/2024
3.2.1 23,289 10/4/2024
3.2.0 2,353 9/22/2024
3.1.2 46,718 6/30/2024
3.1.0-beta 924 1/2/2024
3.0.6 144,531 6/6/2023
3.0.5 570 6/3/2023
3.0.4 167,322 3/11/2023
Loading failed

* Added CustomHttpClientFactory property on DownloadConfiguration to inject a fully custom HttpClient instance (e.g. from IHttpClientFactory). When set, the Downloader bypasses all internal handler and header configuration.
           * Added CustomHttpMessageHandlerFactory property on DownloadConfiguration to inject a custom HttpMessageHandler while still letting Downloader configure default headers and timeout.
           * Added WithHttpClient() and WithHttpMessageHandler() fluent builder methods on DownloadBuilder for easy factory configuration.
           * Added proper HttpClient ownership semantics: externally provided HttpClient instances are never disposed by the Downloader; internally created clients are disposed on cleanup.
           * Added proper HttpMessageHandler ownership semantics: when a custom handler factory is used, the handler is not disposed with the HttpClient (disposeHandler: false), keeping ownership with the caller.
           * Added SocketClient disposal in AbstractDownloadService.Clear() to prevent HttpClient/socket leaks across download sessions.