Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix potential infinite loop in FakeTimeProvider when a timer callback changes the current time #4277

Merged
merged 1 commit into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class FakeTimeProvider : TimeProvider
internal readonly HashSet<Waiter> Waiters = new();
private DateTimeOffset _now = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero);
private TimeZoneInfo _localTimeZone = TimeZoneInfo.Utc;
private int _wakeWaitersGate;
private volatile int _wakeWaitersGate;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't think volatile was needed when you read the value using Interlocked.CompareExchange?

Is it because it is being set by direct assignment later?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't think volatile was needed when you read the value using Interlocked.CompareExchange?

Is it because it is being set by direct assignment later?

Yes, just as an added precaution against funny business in the compiler.

private TimeSpan _autoAdvanceAmount;

/// <summary>
Expand Down Expand Up @@ -59,6 +59,7 @@ public FakeTimeProvider(DateTimeOffset startDateTime)
/// <remarks>
/// This defaults to <see cref="TimeSpan.Zero"/>.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">if the time value is set to less than <see cref="TimeSpan.Zero"/>.</exception>
public TimeSpan AutoAdvanceAmount
{
get => _autoAdvanceAmount;
Expand Down Expand Up @@ -88,6 +89,7 @@ public override DateTimeOffset GetUtcNow()
/// Sets the date and time in the UTC time zone.
/// </summary>
/// <param name="value">The date and time in the UTC time zone.</param>
/// <exception cref="ArgumentOutOfRangeException">if the supplied time value is before the curent time.</exception>
public void SetUtcNow(DateTimeOffset value)
{
lock (Waiters)
Expand All @@ -113,6 +115,7 @@ public void SetUtcNow(DateTimeOffset value)
/// marches forward automatically in hardware, for the fake time provider the application is responsible for
/// doing this explicitly by calling this method.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">if the time value is less than <see cref="TimeSpan.Zero"/>.</exception>
public void Advance(TimeSpan delta)
{
_ = Throw.IfLessThan(delta.Ticks, 0);
Expand Down Expand Up @@ -147,7 +150,7 @@ public override long GetTimestamp()
/// Sets the local time zone.
/// </summary>
/// <param name="localTimeZone">The local time zone.</param>
public void SetLocalTimeZone(TimeZoneInfo localTimeZone) => _localTimeZone = localTimeZone;
public void SetLocalTimeZone(TimeZoneInfo localTimeZone) => _localTimeZone = Throw.IfNull(localTimeZone);

/// <summary>
/// Gets the amount by which the value from <see cref="GetTimestamp"/> increments per second.
Expand Down Expand Up @@ -240,15 +243,29 @@ private void WakeWaiters()
return;
}

var oldTicks = _now.Ticks;

// invoke the callback
candidate.InvokeCallback();

var newTicks = _now.Ticks;

// see if we need to reschedule the waiter
if (candidate.Period > 0)
{
// update the waiter's state
candidate.ScheduledOn = _now.Ticks;
candidate.WakeupTime += candidate.Period;
candidate.ScheduledOn = newTicks;

if (oldTicks != newTicks)
{
// time changed while in the callback, readjust the wake time accordingly
candidate.WakeupTime = newTicks + candidate.Period;
}
else
{
// move on to the next period
candidate.WakeupTime += candidate.Period;
}
Comment on lines +259 to +268
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good solution. This is safe because the current thread that is invoking the waiters/callback is the only one that can change _ now, since it is protected by the lock, and it is only that thread that can reenter the lock. So a change to _now could only come from the same thread.

Is my understanding correct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly :-)

}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,5 +344,21 @@ public void ToString_AutoAdvance_on()
timeProvider.AutoAdvanceAmount = TimeSpan.Zero;
Assert.Equal(timeProvider.Start, timeProvider.GetUtcNow());
}
}

[Fact]
public void AdvanceTimeInCallback()
{
var oneSecond = TimeSpan.FromSeconds(1);
var timeProvider = new FakeTimeProvider();

var timer = timeProvider.CreateTimer(_ =>
{
// Advance the time with exactly the same amount as the period of the timer. This could lead to an
// infinite loop where this callback repeatedly gets invoked. A correct implementation however
// will adjust the timer's wake time so this won't be a problem.
timeProvider.Advance(oneSecond);
}, null, TimeSpan.Zero, oneSecond);

Assert.True(true, "Yay, we didn't enter an infinite loop!");
}
}