Table of Contents

Blazor InputFile Processing

This article demonstrates ways to handle processing files selected in an InputFile control including progress and cancellation.

I recently answered a question on using the the InputFile control. The question was focused on how to deal with exceptions generated by and cancellation of the activity associated with selecting one or more files.

The question highlighted issues with the MS Learn articles that the asking party had used as the basis for their code, and the surfacing of exceptions produced in code running within a Task context.

I'll not go into the code that was presented, but focus on the code I came up with to address both the highlighted issues and some underlying problems the asker had not yet considered.

The Scenario

A page where you select one or more text files and the application calculates the number and frequency of unique words in each document. The word algorithms are unimportant [they're elimentary], it's the state managment, exception and cancellation handling logic that this article focuses on.

TaskCompletionSource

Before diving into the code, it's important to understand what the TaskCompletionSource object gives you as a coder, and how to use it.

TaskCompletionSource is an object that provides control over a Task. The Task is accessed through the Task property. When a TaskCompletionSource is created it's Task's state is running. To complete the Task you call SetResult. There are also methods to handle setting the cancelled and error states on the Task.

The UI

The UI has:

  • a InputFile control with a cancellation button
  • a progress bar
  • an exception alert if an excpetion os generated
  • a list of process files wih their word count
@page "/"
@inject FileCounterProvider FileCounterProvider
@implements IDisposable
<PageTitle>Index</PageTitle>

<h1>Word Counter</h1>

<div class="input-group mb-3">
    <InputFile disabled="@_isRunning" class="form-control" OnChange="@LoadFilesAsync" multiple />
    <div class="input-group-append">
        <button disabled="@_isNotRunning" class="btn btn-danger" @onclick="Cancel">Cancel</button>
    </div>
</div>

@if (_isRunning)
{
    <div class="progress m-3">
        <div class="progress-bar" style="width:@_progressBarStyle;" role="progressbar" aria-valuenow="@_processedPercent" aria-valuemin="0" aria-valuemax="100"></div>
    </div>
}

@if (_exceptionString is not null)
{
    <div class="alert alert-primary">
        @_exceptionString
    </div>
}

@if (this.FileCounterProvider.FileDataList.Count() > 0)
{
    <div class="bg-dark text-white p-2 m-2">
        @foreach (var data in this.FileCounterProvider.FileDataList)
        {
            <pre>File: @data.FileName - Words : @data.Words.Count</pre>

        }
    </div>
}

We have a set of private class variables for UI state.

The important one is the CancellationTokenSource. This manages a cancellation token. It's nullable as it only has a value when the file process is running. IsRunning checks if it's null.

@code {
    private int maxAllowedFiles = 3;
    private bool _isNotRunning => _cancellationTokenSource is null;
    private bool _isRunning => _cancellationTokenSource is not null;
    private string? _exceptionString;
    private long _processedPercent;
    private string _progressBarStyle => $"{_processedPercent}%";
    private CancellationTokenSource? _cancellationTokenSource;

The component methods are fairly standard stuff. We wire up the FileCounterProvider.ProcessedUpdated, implement IDisposable and disconnect te event handler in Dispose. The handler sets a local value and initiates a component render.

    protected override void OnInitialized()
    {
        FileCounterProvider.ProcessedPercentUpdated += OnProcessUpdated;
    }

    private void OnProcessUpdated(object? sender, long Value)
    {
        _processedPercent = Value;
        this.InvokeAsync(StateHasChanged);
    }

    public void Dispose()
        => FileCounterProvider.ProcessedPercentUpdated -= OnProcessUpdated;

LoadFilesAsync handles the InputFile change event. It resets various Ui variables, creates a new CancellationTokenSource and calls FileCounterProvider.LoadFiles, passing it the CancellationToken associated with the CancellationTokenSource.

The cancel button handler simply calls Cancel on the CancellationTokenSource.

    private async Task LoadFilesAsync(InputFileChangeEventArgs e)
    {
        _processedPercent = 0;
        _exceptionString = null;
        _cancellationTokenSource = new();

        try
        {
            var result = await this.FileCounterProvider.LoadFiles(e.GetMultipleFiles(maxAllowedFiles), _cancellationTokenSource.Token);
        }
        catch (Exception ex)
        {
            Console.WriteLine("Exception");
            _exceptionString = ex.Message;
        }
        finally
        {
            _cancellationTokenSource = null;
        }
    }

    private void Cancel()
        => _cancellationTokenSource?.Cancel();

FileData

FileData is a simple class to manage the word data associated with a file. The word count logic is rudimentary: the article isn't about how to make this complete.

public class FileData
{
    private Dictionary<string, int> _words = new();

    public string FileName { get; private set; } = string.Empty;
    public IReadOnlyDictionary<string, int> Words => _words.AsReadOnly();

    public FileData(string fileNme)
    {
        FileName = fileNme;
    }

    public void ProcessText(string? words)
    {
        if (words is null)
            return;

        var foundWords = words.Split(" ");

        foreach (var word in foundWords)
            this.AddWord(word);
    }

    public void AddWord(string? word)
    {
        if (word is null)
            return;

        var loweredWord = word.ToLower();
        if (_words.ContainsKey(loweredWord))
            _words[loweredWord]++;
        else
            _words.Add(loweredWord, 1);
    }
}

FileCounterProvider

The public face of the Provider. It has:

  • A readonly IEnumerable collection of the processes file data
  • An Event tht is raisef when the precent uploaded has been updated.
  • A method to start the file processing.
public class FileCounterProvider
{
    public IEnumerable<FileData> FileDataList => _fileDataList.AsEnumerable();
    public event EventHandler<long>? ProcessedPercentUpdated;

    public Task<bool> LoadFiles(IReadOnlyList<IBrowserFile> files, CancellationToken cancellationToken);

The private class variables. The two important ones are:

  • _runningTask is where the Task thjat is running the processing is assigned.
  • _taskCompletionSource is our Task manager.
public class FileCounterProvider
{
    private long maxFileSize = 1024 * 1024 * 1024;
    private List<FileData> _fileDataList = new List<FileData>();
    private Task _runningTask = Task.CompletedTask;
    private TaskCompletionSource<bool>? _taskCompletionSource;
    private int _linesPerUpdate = 25;

LoadFiles starts the processing. It:

  1. Checks if we are already running. If so then just return false, and let the calling decide what to do.
  2. Create a new TaskCompletionSource.
  3. Start ProcessFiles and assign the Task to _runningTask.
  4. Return the Task associated with _taskCompletionSource.

Note that we are returning the Task from _taskCompletionSource which we have control over. We'll see how we control this shortly.

public Task<bool> LoadFiles(IReadOnlyList<IBrowserFile> files, CancellationToken cancellationToken)
{
    // If the manual task is currently running then return a false
    if (_runningTask.IsCompleted)
        return Task.FromResult(false);

    // Create a new "running" manual task
    _taskCompletionSource = new();
    // start the internal process and assign the task
    // there's no await
    _runningTask = ProcessFiles(files, cancellationToken);

    // Return the running manual task
    return _taskCompletionSource.Task;
}

Next the reader for each file. I've added inline commentary to explain the functionality. Note that there's no exception handling here. any exception will feed straight back to the caller. If the user sets the cancellation token, the method simply stops processing and returns. Note the using on the file stream methods to ensure they are disposed correctly, exception or no exception.

private async Task ReadAsync(IBrowserFile file, CancellationToken cancellationToken)
{
    var fileData = new FileData(file.Name);
    var fileSize = file.Size;

    using var stream = file.OpenReadStream(maxFileSize, cancellationToken);
    using var reader = new StreamReader(stream);

    var line = await reader.ReadLineAsync();
    long processed = 0;
    int lines = 0;

    while (line is not null)
    {
        lines++;
        processed = processed + (line.Length);

        // Slow it down so we can cancel it manually
        await Task.Delay(1);

        // check the cancellation task to see if the user wants out.
        if (cancellationToken.IsCancellationRequested)
            return;

        // process the line
        fileData.ProcessText(line);

        // Raise the Processed event every 25 lines to update the progress in the UI
        if (lines > _linesPerUpdate)
        {
            var percent = ((processed * 100) / fileSize);
            this.ProcessedPercentUpdated?.Invoke(null, percent);
            lines = 0;
        }

        // Read the next line
        line = await reader.ReadLineAsync();
    }

    // Add the processed data to the File Data list
    _fileDataList.Add(fileData);

    // Set the final progress value to 100% as we've finished
    this.ProcessedPercentUpdated?.Invoke(null, 100);
}

Finally the LoadFiles method. This handles all the exception handling and cancellation. Note that when ReadAsync returns we check the cancellation state to see ReadAsync existed early due to a cancellation. If it does, we call cancellationToken.ThrowIfCancellationRequested. We capture exceptions and then set the _taskCompletionSource correctly for what happens in the processing. So if:

  1. The process completes, it calls SetResult.
  2. The process stops due to a cancellation, it calls SetCanceled.
  3. The process errors with an excpetion, it calls SetException.
private async Task ProcessFiles(IReadOnlyList<IBrowserFile> files, CancellationToken cancellationToken)
{
    _fileDataList = new List<FileData>();

    foreach (var file in files)
    {
        try
        {
            await ReadAsync(file, cancellationToken);

            // check the cancellation task to see if the user wants out.
            if (cancellationToken.IsCancellationRequested)
                cancellationToken.ThrowIfCancellationRequested();
        }

        // catch a cancellation exception and pass it by setting the manual task
        catch (OperationCanceledException)
        {
            _taskCompletionSource?.SetCanceled(cancellationToken);
            return;
        }

        // Catch any other task and pass it on through the manula task.
        catch (Exception ex)
        {
            _taskCompletionSource?.SetException(ex);
            return;
        }
    }

    // everything completed so set the Task to Complete.
    _taskCompletionSource?.SetResult(true);
}

Summary

The key concept user here is a Task that you are the coder have control over. The Task or Tasks returned within the processing are internal to FileCounterProvider. The UI can await the managed Task and only continue once processing within the FileCounterProvider completes.