Welcome to CSharp Labs

Asynchronous, Resumable Downloads with HttpDownloadClient

Monday, July 22, 2013

There are several components in .NET designed to facilitate downloading from a remote resource such as the HttpClient or the WebClient classes. Unfortunately, these classes do not provide a system to track progress, save to a stream or resume partially completed downloads. I have created the HttpDownloadClient class to satisfy these requirements, including several additional features.

How it Works

The HttpDownloadClient uses a hybrid combination of thread spinning and blocking to allow the AsyncDownload method to wait for a previously called AsyncInitialize method to complete. The non-blocking synchronization is achieved through an atomic exchange using the Interlocked class in the AsyncInitialize method:

        /// <summary>
        /// Initializes the download.
        /// </summary>
        public async Task AsyncInitialize()
        {
            // CompareExchange is used to take control of _InitializingSyncPoint,  
            // and to determine whether the attempt was successful.  
            // CompareExchange attempts to put 1 into _InitializingSyncPoint, but 
            // only if the current value of _InitializingSyncPoint is zero  
            // (specified by the third parameter). If another thread 
            // has set _InitializingSyncPoint to 1 or -1, an exception is raised.
            if (Interlocked.CompareExchange(ref _InitializingSyncPoint, 1, 0) == 0)
            {
                try
                {
                    await AsyncInitializeDownload();
                }
                finally
                {
                    //release control of _InitializingSyncPoint,
                    //allows waiting download to commence
                    _InitializingSyncPoint = -1;
                }
            }
            else
                throw new NotSupportedException(DOWNLOAD_INITIALIZING_EXCEPTION);
        }

In the AsyncDownload method, a loop attempting another atomic exchange, blocks until the AsyncInitialize method has completed:

                    //this allows the Download method to wait for a previously called
                    //AsyncInitialize to complete
                    for (; ; )
                    {
                        int pt = Interlocked.CompareExchange(ref _InitializingSyncPoint, 1, 0);

                        //if original value was 0 (no initialization) or 
                        //-1 (already initialized) break out of loop
                        if (pt == 0 || pt == -1)
                            break;

                        //if a CancellationToken was not provided, delay;
                        //otherwise, delay until the _CancellationToken is signaled or delay is elapsed
                        if (_CancellationToken == null)
                            await Task.Delay(1);
                        else
                        {
                            await Task.Delay(1, _CancellationToken);
                            _CancellationToken.ThrowIfCancellationRequested();
                        }
                    }

The download is ensured to be initialized if applicable:

                    //initialize download
                    if (!DownloadInitialized && (RequiresInitialization || AllowResuming))
                    {
                        await AsyncInitializeDownload();

                        //if DownloadFailed or Canceled is true, initialization failed
                        if (DownloadFailed || DownloadCanceled || DownloadCompleted)
                            return;
                    }

Download initialization is completed through the following process:

  • Create a request to the remote resource.
  • Read the web response to determine target file size and modification date.
  • Determine if any data exists in buffer that can be resumed.

Initialization can be called before downloading will commence to gather the content length for concurrently downloads. All requests and delays take advantage of the await keyword for asynchronous operation:

            for (; ; )
            {
                //increment download attempts
                initializationAttempts++;

                HttpWebRequest request = null;

                try
                {
                    //preemptive cancellation check
                    if (_CancellationToken != null)
                        _CancellationToken.ThrowIfCancellationRequested();

                    //create a request to the target file
                    request = CreateRequest(Target);

                    //get the response
                    using (HttpWebResponse response = await request.GetResponseAsync() as HttpWebResponse)
                    {
                        //if the Content-Length header is not set in the response
                        //set TotalBytes to null
                        if (response.ContentLength < 0)
                            //unknown data length
                            TotalBytes = null;
                        else
                            //known data length
                            TotalBytes = response.ContentLength;

                        //if resuming is allowed, determine if the file can be resumed
                        if (AllowResuming)
                        {
                            //download can be resumed if saving to a stream or if
                            //the remote file was not modified after the file was modified
                            if (DownloadMethod == DownloadMethod.ToStream)
                                BytesDownloaded = _TargetStream.Length; //how much we have already downloaded
                            else
                            {
                                FileInfo fi = new FileInfo(_TargetFile);

                                if (fi.Exists)
                                    //how much we have already downloaded
                                    BytesDownloaded = fi.Length;
                                else
                                    BytesDownloaded = 0L;
                            }

                            //attempt to resume
                            if (BytesDownloaded > 0)
                            {
                                if (!TotalBytes.HasValue || TotalBytes < BytesDownloaded)
                                    //Cannot retrieve remote size or wrong size so delete/clear what we have so far.
                                    ClearBuffers();
                                else if (DownloadMethod == DownloadMethod.ToFile)
                                {
                                    //determine if the time is wrong
                                    DateTime lastModifiedRemote = (response as HttpWebResponse).LastModified.ToUniversalTime();
                                    DateTime lastModifiedLocal = File.GetLastWriteTimeUtc(_TargetFile);

                                    if (lastModifiedRemote > lastModifiedLocal) //compare the local and remote timestamps
                                    {
                                        //Remote file is newer then local.
                                        ClearBuffers();
                                    }
                                }

                                if (BytesDownloaded == TotalBytes)
                                {
                                    //download has completed
                                    DownloadCompleted = true;
                                    //push the whole download length as a single packet received
                                    _HttpDownloadPacketReceived(BytesDownloaded);
                                    //raises HttpDownloadProgressChanged event if percent completed changed
                                    _HttpDownloadProgressChanged();
                                    //raise download complete event
                                    _HttpDownloadComplete();

                                    return;
                                }
                            }
                        }
                        else
                            ClearBuffers(); //no resuming, clear the buffers
                    }

                    //break out of infinite loop
                    break;
                }
                catch (OperationCanceledException)
                {
                    //catch and throw OperationCanceledException to outer try-catch
                    throw;
                }
                catch
                {
                    //if maximum initialization attempts are exceeded
                    if (initializationAttempts >= maxInitializationAttempts)
                        throw; //throw exception to outer try-catch
                }
                finally
                {
                    //finally clause aborts request
                    if (request != null)
                        request.Abort();
                }

                //determine if suitable delay defined
                if (RepeatDelay > 0)
                {
                    //if a CancellationToken was not provided, delay;
                    //otherwise, delay until _CancellationToken is signaled or RepeatDelay is elapsed
                    if (_CancellationToken == null)
                        await Task.Delay(RepeatDelay);
                    else
                    {
                        await Task.Delay(RepeatDelay, _CancellationToken);
                        _CancellationToken.ThrowIfCancellationRequested();
                    }
                }
            }

If the download cannot be resumed due to invalid timestamps or data lengths, the file or stream buffers are cleared and the download will be started over.

File downloading is completed through the following process:

  • Create a request to the remote resource, including the desired data range for resuming.
  • Read the web response, determine if partial content is supported for resuming.
  • Read the response stream and copy data to destination stream.
  • If an error occurs, attempt to resume if supported and permitted.

All requests, reads, writes and delays take advantage of the await keyword for asynchronous operation:

                    //infinite loop allows repeated download attempts
                    for (; ; )
                    {
                        //increment DownloadAttempts
                        DownloadAttempts++;

                        HttpWebRequest request = null;

                        try
                        {
                            //preemptive cancel check
                            if (_CancellationToken != null)
                                _CancellationToken.ThrowIfCancellationRequested();

                            //create a HttpWebRequest from the Uri
                            request = CreateRequest(Target);

                            //if download can be resumed
                            if (AllowResuming && BytesDownloaded > 0)
                                //set the requested range
                                request.AddRange(BytesDownloaded);
                            else
                                //no resuming, clear the buffers
                                ClearBuffers();

                            using (HttpWebResponse response = await request.GetResponseAsync() as HttpWebResponse)
                            {
                                //look for partial content
                                if (AllowResuming && BytesDownloaded > 0 &&
                                    response.StatusCode != HttpStatusCode.PartialContent)
                                    //cannot resume, no partial content
                                    ClearBuffers();

                                if (BytesDownloaded > 0)
                                {
                                    //resuming, notify we got a packet (previously)
                                    _HttpDownloadPacketReceived(BytesDownloaded);
                                    //raises HttpDownloadProgressChanged event if percent completed changed
                                    _HttpDownloadProgressChanged();
                                }

                                //get the response stream
                                using (Stream downloadStream = response.GetResponseStream())
                                {
                                    //defines the stream to write to
                                    Stream str = null;

                                    //indicates the number of bytes read in the last packet
                                    int readCount;

                                    try
                                    {
                                        //if downloading to a stream, use the target stream;
                                        //otherwise, use the file
                                        if (DownloadMethod == DownloadMethod.ToStream)
                                        {
                                            //use target stream
                                            str = _TargetStream;

                                            //set the stream position to the end
                                            str.Seek(0, SeekOrigin.End);
                                        }
                                        else
                                            //open target file
                                            str = File.Open(_TargetFile, FileMode.Append, FileAccess.Write, FileShare.None);

                                        //buffer for data
                                        byte[] buffer = new byte[0x1000];

                                        //read from the download stream
                                        while ((readCount = await downloadStream.ReadAsync(buffer, 0, buffer.Length, _CancellationToken)) > 0)
                                        {
                                            //write the data to the file
                                            await str.WriteAsync(buffer, 0, readCount, _CancellationToken);

                                            //increment the bytes count
                                            BytesDownloaded += readCount;

                                            //raises HttpDownloadPacketReceived event
                                            _HttpDownloadPacketReceived(readCount);

                                            //raises HttpDownloadProgressChanged event if percent completed changed
                                            _HttpDownloadProgressChanged();

                                            //cancel check
                                            if (_CancellationToken != null)
                                                _CancellationToken.ThrowIfCancellationRequested();
                                        }
                                    }
                                    finally
                                    {
                                        //dispose the stream if target file
                                        if (str != null && DownloadMethod == DownloadMethod.ToFile)
                                            str.Dispose();
                                    }

                                    //download completed only if no more bytes are returned
                                    if (readCount == 0)
                                        //set DownloadCompleted flag indicating successful download
                                        DownloadCompleted = true;
                                }
                            }

                            //break the infinite loop
                            break;
                        }
                        catch (OperationCanceledException)
                        {
                            //catch and throw OperationCanceledException to outer try-catch
                            throw;
                        }
                        catch (Exception)
                        {
                            //if maximum download attempts are exceeded, throw the exception
                            if (DownloadAttempts >= MaxDownloadAttempts)
                                //throw exception to outer try-catch
                                throw;
                        }
                        finally
                        {
                            //finally clause aborts request
                            if (request != null)
                                request.Abort();
                        }

                        //determine if suitable delay defined
                        if (RepeatDelay > 0)
                        {
                            //if a CancellationToken was not provided, delay; 
                            //otherwise, delay until _CancellationToken is signaled or RepeatDelay is elapsed
                            if (_CancellationToken == null)
                                await Task.Delay(RepeatDelay);
                            else
                            {
                                await Task.Delay(RepeatDelay, _CancellationToken);
                                _CancellationToken.ThrowIfCancellationRequested();
                            }
                        }
                    }

AsyncInitialize and AsyncInitialize can be called synchronously by using the respected Initialize and Download methods:

        /// <summary>
        /// Initializes the download.
        /// </summary>
        public void Initialize()
        {
            Task t = AsyncInitialize();

            //wait for task to complete
            t.Wait();

            //throw any exception
            if (t.IsFaulted)
                throw t.Exception;
        }

        /// <summary>
        /// Begins the download.
        /// </summary>
        public void Download()
        {
            Task t = AsyncDownload();

            //wait for task to complete
            t.Wait();

            //throw any exception
            if (t.IsFaulted)
                throw t.Exception;
        }
Exceptions

If an error occurs while initializing or downloading, the HttpDownloadClient will attempt the operation again as permitted by the MaxDownloadAttempts property. If the maximum download attempts is exceeded, the HttpDownloadInitializationFailed or HttpDownloadException event will be raised with the last exception thrown.

Cancellation

The HttpDownloadClient can be initialized with a CancellationToken which will be monitored during initialization and downloading. If the CancellationToken is triggered during operation, the HttpDownloadInititializationCanceled or HttpDownloadCanceled event is raised.

Using

I've created an example form which asynchronously downloads a file. A progress bar is updated and the download can be stopped and resumed:

    using System;
    using System.IO;
    using System.Net;
    using System.Threading;
    using System.Windows.Forms;

    public class DownloadTestForm : Form
    {
        private Button StopBtn;
        private ProgressBar DownloadProgress;
        private Button DownloadBtn;

        /// <summary>
        /// Required designer variable.
        /// </summary>
        private System.ComponentModel.IContainer components = null;

        /// <summary>
        /// Clean up any resources being used.
        /// </summary>
        /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (_CancellationTokenSource != null)
                    _CancellationTokenSource.Cancel();

                if (components != null)
                    components.Dispose();
            }
            base.Dispose(disposing);
        }

        #region Windows Form Designer generated code

        /// <summary>
        /// Required method for Designer support - do not modify
        /// the contents of this method with the code editor.
        /// </summary>
        private void InitializeComponent()
        {
            this.StopBtn = new System.Windows.Forms.Button();
            this.DownloadProgress = new System.Windows.Forms.ProgressBar();
            this.DownloadBtn = new System.Windows.Forms.Button();
            this.SuspendLayout();
            // 
            // StopBtn
            // 
            this.StopBtn.Enabled = false;
            this.StopBtn.Location = new System.Drawing.Point(316, 12);
            this.StopBtn.Name = "StopBtn";
            this.StopBtn.Size = new System.Drawing.Size(75, 23);
            this.StopBtn.TabIndex = 5;
            this.StopBtn.Text = "Stop";
            this.StopBtn.UseVisualStyleBackColor = true;
            this.StopBtn.Click += new System.EventHandler(this.StopBtn_Click);
            // 
            // DownloadProgress
            // 
            this.DownloadProgress.Location = new System.Drawing.Point(12, 12);
            this.DownloadProgress.Name = "DownloadProgress";
            this.DownloadProgress.Size = new System.Drawing.Size(217, 23);
            this.DownloadProgress.TabIndex = 4;
            // 
            // DownloadBtn
            // 
            this.DownloadBtn.Location = new System.Drawing.Point(235, 12);
            this.DownloadBtn.Name = "DownloadBtn";
            this.DownloadBtn.Size = new System.Drawing.Size(75, 23);
            this.DownloadBtn.TabIndex = 3;
            this.DownloadBtn.Text = "Start";
            this.DownloadBtn.UseVisualStyleBackColor = true;
            this.DownloadBtn.Click += new System.EventHandler(this.DownloadBtn_Click);
            // 
            // DownloadTestForm
            // 
            this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
            this.ClientSize = new System.Drawing.Size(406, 48);
            this.Controls.Add(this.StopBtn);
            this.Controls.Add(this.DownloadProgress);
            this.Controls.Add(this.DownloadBtn);
            this.Name = "DownloadTestForm";
            this.Text = "DownloadTestForm";
            this.ResumeLayout(false);

        }

        #endregion

        public DownloadTestForm()
        {
            InitializeComponent();
        }

        /// <summary>
        /// Used to signal cancellation.
        /// </summary>
        private CancellationTokenSource _CancellationTokenSource;

        /// <summary>
        /// Stream to copy remote data to.
        /// </summary>
        private MemoryStream _TestStream = new MemoryStream();

        private async void DownloadBtn_Click(object sender, EventArgs e)
        {
            //disable download button
            DownloadBtn.Enabled = false;

            //create cancellation token
            using (_CancellationTokenSource = new CancellationTokenSource())
            {
                //create download client from a target file and destination stream
                HttpDownloadClient _Client = new HttpDownloadClient(new Uri("[target file]"), _TestStream, true, _CancellationTokenSource.Token);

                //subscribe to the HttpDownloadProgressChanged event to
                //monitor progress. since this event is called on a separate thread,
                //invoke to UI thread to update progress bar
                _Client.HttpDownloadProgressChanged += (obj, arg) =>
                {
                    this.Invoke(() =>
                    {
                        //PercentComplete can be null if the remote resource 
                        //content-length is unknown
                        int? progress = arg.PercentComplete;

                        if (progress.HasValue)
                        {
                            //ensure block style
                            DownloadProgress.Style = ProgressBarStyle.Blocks;
                            //set percent complete
                            DownloadProgress.Value = arg.PercentComplete.Value;
                        }
                        else
                            //if no content-length, switch to a marquee style
                            DownloadProgress.Style = ProgressBarStyle.Marquee;
                    });
                };

                //clears buffer for testing
                _Client.HttpDownloadComplete += (obj, arg) =>
                {
                    _TestStream.SetLength(0L);
                };

                //enable stop button to stop download
                StopBtn.Enabled = true;

                //download file asynchronously
                await _Client.AsyncDownload();
            }

            //prevents dispose method from attempting cancellation
            _CancellationTokenSource = null;

            //enable download button to restart or resume download
            DownloadBtn.Enabled = true;
        }

        private void StopBtn_Click(object sender, EventArgs e)
        {
            //cancel download
            _CancellationTokenSource.Cancel();

            //disable stop button
            StopBtn.Enabled = false;
        }
    }

The previous example uses an extension method for the invoke call, see Using Lambda Expressions in Control Invoke Calls. Downloading multiple files using a single progress bar can be achieved by initializing each download and subscribing to the HttpDownloadPacketReceived method to increment progress.

Source Code

Download HttpDownloadClient and Supporting Classes

Comments