Skip to content

The static ChangeToken.OnChange method throws a StackOverflowException when its producer factory returns the same change token instance #60183

Open
@IEvangelist

Description

@IEvangelist

Description

The static ChangeToken.OnChange method throws a StackOverflowException when its producer factory returns the same change token instance.

Reproduction Steps

You will need a project with the Microsoft.Extensions.Primitives NuGet package installed. The following C# code demonstrates the bug:

using System;
using System.Threading;
using Microsoft.Extensions.Primitives;

CancellationTokenSource cancellationTokenSource = new();
CancellationChangeToken cancellationChangeToken = new(cancellationTokenSource.Token);

Console.WriteLine($"Initial state of HasChanged: {cancellationChangeToken.HasChanged}");

Func<IChangeToken> producer = () =>
{
    return cancellationChangeToken;
};

Action consumer = () => Console.WriteLine("The callback was invoked.");

using (ChangeToken.OnChange(producer, consumer))
{
    cancellationTokenSource.Cancel();
}

Expected behavior

I would expect that this method would not throw a StackOverflowException.

Actual behavior

A StackOverflowException is thrown. The ChangeToken.OnChange function registers the producer and corresponding consumer. When the producer triggers a change, i.e.; the cancellationTokenSource.Cancel is called, the consumer callback is executed and then the producer is called again to get a new change token. However, the same change token is returned. And since it has already fired, it immediately causes the re-registration to invoke the callback inline. This creates an infinite loop, where the symptom is the SOE.

Regression?

It is my understanding that this has always been an issue, it's just never been raised before. This ChangeToken is applicable to the following .NET builds:

.NET Platform Extensions 1.0, 1.1, 2.0, 2.1, 2.2, 3.0, 3.1, 5.0, 6.0 RC 1

Known Workarounds

The known workaround is to ensure that the producer either returns a new change token, or at least evaluates the previous state and resets itself (if possible).

using System;
using System.Threading;
using Microsoft.Extensions.Primitives;

CancellationTokenSource cancellationTokenSource = new();
CancellationChangeToken cancellationChangeToken = new(cancellationTokenSource.Token);

Console.WriteLine($"Initial state of HasChanged: {cancellationChangeToken.HasChanged}");

Func<IChangeToken> producer = () =>
{
    // The producer factory should always return a new change token.
    // If the token's already fired, get a new token.
    if (cancellationTokenSource.IsCancellationRequested)
    {
        cancellationTokenSource = new();
        cancellationChangeToken = new(cancellationTokenSource.Token);
    }

    return cancellationChangeToken;
};

Action consumer = () => Console.WriteLine("The callback was invoked.");

using (ChangeToken.OnChange(producer, consumer))
{
    cancellationTokenSource.Cancel();
}

Configuration

No response

Other information

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions