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.
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.
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 has:
InputFile
control with a cancellation button@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 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); } }
The public face of the Provider. It has:
IEnumerable
collection of the processes file dataEvent
tht is raisef when the precent uploaded has been updated.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:
TaskCompletionSource
.ProcessFiles
and assign the Task
to _runningTask
._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:
SetResult
.SetCanceled
.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); }
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.