Skip to content

Passing already cancelled token to method - OperationCanceledException not thrown #655

@perepechenko

Description

@perepechenko

First of all, I would like to thank you for the amazing plugin that we actively use in our games.

Some time ago, we encountered unexpected behavior:
We had code that was supposed to call Dispose() when going out of scope. Then, there was a call to FirstAsync(), which was waiting for the required state, and after that, Dispose() was supposed to be called. However, in some cases, Dispose() was never called.

private readonly AsyncReactiveProperty<State> _state = new(State.None);

private async UniTask MyMethodAsync(CancellationToken cancellationToken)
{
    using var myObj = new MyObj();
    await _state.FirstAsync(x => x == State.MyState, cancellationToken);
}

This happened because a canceled token was passed to FirstAsync() (the developer did not check the token before calling the method). That is, in a situation when the developer did not check for the token to be canceled there will be a situation when the await method will await always.

Another example:

CancellationTokenSource cts = new CancellationTokenSource();
var cancellationToken = cts.Token;

var rp = new AsyncReactiveProperty<int>(9);

rp.ForEachAsync(x =>
{
    Debug.Log(x);
}, cancellationToken).Forget();

rp.Value = 10; // push 10 to all subscriber
rp.Value = 11; // push 11 to all subscriber
cts.Cancel();
rp.Value = 12; // push 12 to all subscriber

// Output
9
10
11

Right, after 11 we canceled the token and 12 will no longer be displayed.

And if we change the example:

CancellationTokenSource cts = new CancellationTokenSource();
var cancellationToken = cts.Token;
cts.Cancel();

var rp = new AsyncReactiveProperty<int>(9);

rp.ForEachAsync(x =>
{
    Debug.Log(x);
}, cancellationToken).Forget();

rp.Value = 10; // push 10 to all subscriber
rp.Value = 11; // push 11 to all subscriber
rp.Value = 12; // push 12 to all subscriber

// Output
9

But I, as a developer, expect the code to throw an OperationCanceledException.

According to Microsoft recommended patterns for CancellationToken ( https://devblogs.microsoft.com/premier-developer/recommended-patterns-for-cancellationtoken/ ) I suggest checking the token at the method input and throwing an exception (to detect errors in the calling code).

I made edits in my branch for LINQ methods (add checking cancellation), and added a test that passes with the edits, it does not pass on the current version of UniTask, but pass in my branch.

        [Fact]
        public async Task FirstAsync_WithCanceledCancellationToken_ThrowsOperationCanceledException()
        {
            // Arrange
            var rp = new AsyncReactiveProperty<int>(99);
            var cts = new CancellationTokenSource();
            cts.Cancel();
            var cancellationToken = cts.Token;

            // Act & Assert
            await Assert.ThrowsAsync<OperationCanceledException>(async () =>
            {
                await rp.FirstAsync(x => x == 99, cancellationToken);
            });
        }

What do you think ?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions