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

Add FakeAsync.runNextTimer #85

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open

Conversation

gnprice
Copy link

@gnprice gnprice commented Jun 6, 2024

Fixes #84.

This method is like flushTimers, but runs just one timer and then returns.
That allows the caller to write their own loop similar to flushTimers
but with custom logic of their own.


  • I’ve reviewed the contributor guide and applied the relevant portions to this PR.
Contribution guidelines:

Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.

If we reach this line, `_timers.isNotEmpty` must be true.

That's because this callback's only call site is the line
`if(!predicate(timer)) break;` in `_fireTimersWhile`, and there's
a check a few lines above there that would have broken out of the
loop if `_timers` were empty.
This version is exactly equivalent via Boolean algebra, and will
make for a bit simpler of a diff in the next refactor.
@natebosch natebosch requested a review from lrhn June 28, 2024 00:03
@natebosch
Copy link
Member

I think this looks like a sensible API. I'll double check this doesn't impact existing internal usage. @lrhn any concerns?

if (timer._nextCall > absoluteTimeout) {
// TODO(nweiz): Make this a [TimeoutException].
throw StateError('Exceeded timeout $timeout while flushing timers');
for (;;) {
Copy link
Member

Choose a reason for hiding this comment

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

Usually use while (true) { for this pattern in Dart.

@@ -138,7 +138,7 @@ class FakeAsync {
}

_elapsingTo = _elapsed + duration;
_fireTimersWhile((next) => next._nextCall <= _elapsingTo!);
while (runNextTimer(timeout: _elapsingTo! - _elapsed)) {}
Copy link
Member

Choose a reason for hiding this comment

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

I'd make an internal helper _runNextTimer([Duration? until]) that doesn't take a duration relative to now, but takes the actual end time (duration since initialTime).

That's the time (_elapsingTo) that you already have here, and it's the first thing that runNextTimer computes internally anyway.
It avoids repeatedly doing _elapsingTo - _elapsed.

So:

  final elapsingTo = _elapsingTo = _elapsed + duration;
  while (_runNextTimer(elapsingTo)) {}

Then the public function would be:

bool runNextTimer({Duration? timeout]) {
   if (timeout == null) return runNextTimer();
   var timeoutTime = _elapsed + timeout;
   if (_runNextTimer(timeoutTime)) {
     return true;
   } else {
     _elapsed = timeoutTime;
     return false;
   }
}

(I suggest advancing time by at least the [timeout]. If not, it should be renamed to something else, like before. A timeout only applies if time has actually advanced.)

/// timer runs, [elapsed] is updated to the appropriate value.
///
/// The [timeout] controls how much fake time may elapse. If non-null,
/// then timers further in the future than the given duration will be ignored.
Copy link
Member

Choose a reason for hiding this comment

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

I would still advance the time by timeout before returning false.
Otherwise the name should be different. A timeout triggering means that the time has passed.

I can see that flushTimers doesn't do that, but that's because it throws an error if all (non-periodic) timers are not flushed before the timeout is up, and presumably the test fails then, so advancing the time doesn't matter.
That is, the timeout parameter to runNextTimer is not the same as the one to flushTimers. The latter means "it's an error if we reach it", the former is just "only try to go this far".

If we change the call in flushTimers to the internal _runNextTimer, and it's only the public runNextTimer that advance time on a false result, then we won't change the behavior of flushTimers.

// all remaining timers are periodic *and* every periodic timer has had
// a chance to run against the final value of [_elapsed].
if (!flushPeriodicTimers) {
if (_timers
Copy link
Member

Choose a reason for hiding this comment

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

(Just a comment: So much of this code would be easier to read (and more efficient) if _timers was a priority queue. And if it tracked the number of periodic timers on the side, this check would just be _timers.length == _periodicTimerCount. All this repeated iteration only works because it's for testing only, and there aren't that many timers in one test.)

/// The [timeout] controls how much fake time may elapse. If non-null,
/// then timers further in the future than the given duration will be ignored.
///
/// Returns true if a timer was run, false otherwise.
Copy link
Member

Choose a reason for hiding this comment

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

I'd code-quote true and false. Generally try to code-quote literals when I refer to language-semantic values, like these, void and null, "done" or 42, to distinguish them from plain text containg the same symbols as English words or numbers.

///
/// Returns true if a timer was run, false otherwise.
bool runNextTimer({Duration? timeout}) {
final absoluteTimeout = timeout == null ? null : _elapsed + timeout;
Copy link
Member

Choose a reason for hiding this comment

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

That is, I'd start the internal bool _runNextTimer(Duration? absoluteTimeout) {
here instead.

flushMicrotasks();
for (;;) {
if (_timers.isEmpty) break;
/// Microtasks are flushed before and after the timer runs. Before the
Copy link
Member

Choose a reason for hiding this comment

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

Mention that the current microtask queue is flushed whether or not there is a timer in range.

Copy link
Member

@lrhn lrhn left a comment

Choose a reason for hiding this comment

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

No problem with the API, but suggestions on structuring the implementation.
One change request: Make a reached timeout actually advance the time to that timeout time.

Consider suggesting use-cases for the method in its doc. Something like:

/// Running only one timer at a time, rather than just advancing time, 
/// can be used if a test wants to simulate other asynchronous non-timer events
/// between timer events, for example simulating UI updates
/// or port communication.

(Or something. That's what I could come up with.)

Maybe also say:

  /// Notice that after a call this method, the time may have been advanced 
  /// to where multiple timers are due. Doing an `elapse(Duration.zero)` afterwards
  /// may trigger more timers.

Before adding this method, the only way to advance time was elapse and flushTimers, which both always runs all due timers before exiting.
We should probably check that there is no code assuming that the timers in _timers are not yet due. (Probably not an issue, the code is pretty basic.)

(Should we have an isTimerDue check?)

@natebosch
Copy link
Member

No impact to internal usage, so this is safe to land after review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Single-step version of flushTimers
3 participants