'How do I appropriately dispose of a cancellation token from a continued task?

I created this DelayedTask class below that fires an action after a delay. I realize this functionality is built into Task, but I want to understand what is wrong with this code.

Every now and then I will get a null reference exception within the Begin() function on the _cancellationTokenSource. I'm not sure how that is possible. I tried lock in the CompleteTask function, but that didn't fix it.

I must be cancelling in close proximity to starting a new DelayedTask, but I'm not sure how to write this for it to work properly.

Feel free to critique anything else in this class. I appreciate the feedback.

public class DelayedTask
{

    public bool IsCompleted { get; private set; }
    public bool IsCompletedSuccessfully { get; private set; }
    public bool IsCancelled { get; private set; }
    public bool IsFaulted { get; private set; }

    public AggregateException Exception { get; private set; }

    private readonly Action _action;
    private readonly TimeSpan _delay;
    private CancellationTokenSource _cancelTokenSource;

    private readonly object _lock = new object();


    private DelayedTask(Action action, TimeSpan delay)
    {
        _action = action ?? throw new ArgumentNullException(nameof(action));
        _delay = delay;
    }


    public async Task Begin()
    {
        _cancelTokenSource = new CancellationTokenSource();
        try
        {
            Task delayTask = Task.Delay(_delay, _cancelTokenSource.Token).ContinueWith(CompleteTask);
            await delayTask.ConfigureAwait(false);
        }
        catch (TaskCanceledException)
        {
            IsCancelled = true;
            IsCompleted = true;
            return;
        }
    }


    private void CompleteTask(Task t)
    {
        lock(_lock)
        {
            if (!t.IsCanceled)
            {
                try
                {
                    _action.Invoke();
                    IsCompletedSuccessfully = true;
                }
                catch (Exception ex)
                {
                    Exception = new AggregateException($"Action failed to complete successfully", ex);
                    IsFaulted = true;
                }
            }
            else
                IsCancelled = true;

            IsCompleted = true;
            t.Dispose();
            _cancelTokenSource?.Dispose();
            _cancelTokenSource = null;
        }
    }


    public void Reset()
    {
        //await Task.Run(() => Cancel());
        Cancel();
        IsCompleted = false;
        IsCancelled = false;
        IsFaulted = false;
        IsCompletedSuccessfully = false;
        Exception = null;
        _ = Task.Run(() => Begin());
    }


    public void Cancel()
    {
        if (_cancelTokenSource == null || _cancelTokenSource.IsCancellationRequested)
            return;

        _cancelTokenSource.Cancel();
    }


    public static DelayedTask Set(Action action, TimeSpan delay)
    {
        DelayedTask result = new(action, delay);
        _ = result.Begin();
        return result;
    }
}


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source