Blazor Background Service not stopping/cancelling properly

173 Views Asked by At

I am building a simple app that counts words in files. I chose blazor because I wanted to try it out and because it is relatively easy to keep the interface responsive.

I have a service for doing the actual work. In my UI I am calling the service like this:

private async void InputFileChanged(InputFileChangeEventArgs e)
{
    if (Service.Running)
    {
        return;
    }

    IReadOnlyList<IBrowserFile> files = e.GetMultipleFiles();

    try
    {
        cancellationTokenSource = new CancellationTokenSource();
        wordCounts = await Service.GetWordCounts(files, cancellationTokenSource.Token);
        progressPercentage = 100;
    }
    catch (OperationCanceledException ex)
    {
        Service.Stop();
        wordCounts = null;
        progressPercentage = 0;
    }

    StateHasChanged();
}

Now, you might notice the Service.Stop(); inside the catch block. That is exactly what I'm not happy about.

Essentially, when cancelling the service process, the service throws an exception, as is common with cancellation tokens. When throwing the exception, I am already calling that Stop() method. However, the service seems to never actually reach the code blocks containing throw, even though it is throwing the exception. I checked by using break points, and it never breaks on the throws inside the service.

Any ideas on what I'm doing wrong or what I could improve to not have to call that Stop() method outside of the service?

FileCounterService:

using Microsoft.AspNetCore.Components.Forms;
using System.ComponentModel;

namespace FileWordCounter.Service
{
    public class FileCounterService
    {
        public bool Running { get; private set; }
        public event ProgressChangedEventHandler? ProgressChanged;

        private StreamReader? reader = null;
        private long totalBytes;
        private long progressedBytes;

        public async Task<IOrderedEnumerable<KeyValuePair<string, int>>?> GetWordCounts(
            IEnumerable<IBrowserFile> files,
            CancellationToken cancellationToken)
        {
            //initialize service
            Running = true;
            Dictionary<string, int> wordCounts = new Dictionary<string, int>();

            //reset progress values
            totalBytes = files.Sum(x => x.Size);
            progressedBytes = 0;

            //loop each file
            foreach(IBrowserFile file in files)
            {
                //maximum file size 1 GiB should cover most of the clients files
                long maxFileSize = 1024L * 1024 * 1024 * 1024;
                try
                {
                    reader = new StreamReader(file.OpenReadStream(maxFileSize, cancellationToken));
                }
                catch (OperationCanceledException ex)
                {
                    Stop();
                    throw ex;
                }

                string? line = await reader.ReadLineAsync();

                while (line != null) 
                {
                    if (line == "")
                    {
                        line = await reader.ReadLineAsync();
                        continue;
                    }

                    string[] words = line.Split(' ');
                    foreach (string word in words)
                    {
                        if (string.IsNullOrWhiteSpace(word))
                        {
                            continue;
                        }

                        if (wordCounts.ContainsKey(word))
                        {
                            wordCounts[word]++;
                        }
                        else
                        {
                            wordCounts.Add(word, 1);
                        }

                        //cancel execution in case cancellation was requested
                        if (cancellationToken.IsCancellationRequested)
                        {
                            Stop();
                            throw new OperationCanceledException();
                        }
                    }

                    //since files are ANSI, we can assume 1 byte per character
                    //assuming windows line endings, so 2 additional characters per line
                    progressedBytes += line.Length + 2;

                    //multiply by 100 as float to make sure no comma values are lost
                    int progressPercentage = (int)(progressedBytes * 100f / totalBytes);
                    ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(progressPercentage, null));

                    //cancel execution in case cancellation was requested
                    if (cancellationToken.IsCancellationRequested)
                    {
                        Stop();
                        throw new OperationCanceledException();
                    }
                    line = await reader.ReadLineAsync();
                }

                reader?.Dispose();
            }

            var returnValue = wordCounts.OrderByDescending(wordCount => wordCount.Value);

            Running = false;
            return returnValue;
        }

        public void Stop()
        {
            Running = false;
            reader?.Dispose();
        }
    }
}
1

There are 1 best solutions below

0
On

Any ideas on what I'm doing wrong or what I could improve to not have to call that Stop() method outside of the service?

You are probably trying to achieve your objective the wrong way. Your Stop code is there to reset the state in the case of a cancellation or exception.

I've revamped your code to demonstrate a different approach that uses a manually created Task as the main process control mechanism. Ask a question if there's something you want clarifying in my answer.

You should be able to take this code, drop it into an empty project and run it.

A FileData object to manage and encapsulate the dictionary of words.

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

Next your FileCounterService. It:

  • uses a manually created Task to control the output to the caller.
  • Uses an event to pass progress updates to the UI.
  • Watches the cancellationTask and cancels on request.
  • Handles cancelation and exceptions within the task and passes them back through the Task to the caller.
  • A delay in to slow things down so you can see progress and state.
  • Registered as a transient service.
public class FileCounterService
{
    private long maxFileSize = 1024 * 1024 * 1024;
    private List<FileData> _fileDataList = new List<FileData>();
    private TaskCompletionSource<bool>? _taskCompletionSource;
    private Task _runningTask = Task.CompletedTask;

    public IEnumerable<FileData> FileDataList => _fileDataList.AsEnumerable();
    public event EventHandler<long>? ProcessedUpdated;

    public Task<bool> LoadFiles(IReadOnlyList<IBrowserFile> files, CancellationToken cancellationToken)
    {
        // If the manual task is currently running then return a false
        // Cose what you want to do here.
        if (_runningTask.IsCompleted)
        {
            // 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;
        }
        // If already running return a completed taks set to false
        return Task.FromResult(false);
    }

    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)
            {
               
                Console.WriteLine("Operation cancelled.");
                _taskCompletionSource?.SetCanceled(cancellationToken);
                return;
            }

            // Catch any other task and pass it on through the manula task.
            catch (Exception ex)
            {
                //Console.WriteLine("File: {Filename} Error: {Error}", file.Name, ex.Message);
                _taskCompletionSource?.SetException(ex);
                return;
            }
        }

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

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

            // Slow it doen do we can cancel it
            await Task.Delay(1);

            processed = processed + (line.Length);

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

            fileData.ProcessText(line);

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

            line = await reader.ReadLineAsync();
        }

        _fileDataList.Add(fileData);

        // Set the final progress value to 100%
        this.ProcessedUpdated?.Invoke(null, 100);
    }
}

And finally the Demo Page:

  • Shows the progress for each file.
  • Shows a cancel button when running.
  • Locks the input control when processing.
@page "/"
@inject FileCounterService FileService
@implements IDisposable
<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />

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

        }
    </div>
}

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

    protected override void OnInitialized()
    {
        FileService.ProcessedUpdated += OnProcessUpdated;
    }

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

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

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

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

    public void Dispose()
        => FileService.ProcessedUpdated -= OnProcessUpdated;
}

Note: I haven't checked all the word conting logic, so there may be a bug or two in there.

enter image description here