diff --git a/pkgs/fake_async/CHANGELOG.md b/pkgs/fake_async/CHANGELOG.md index 614c31f5c..69c56fa8b 100644 --- a/pkgs/fake_async/CHANGELOG.md +++ b/pkgs/fake_async/CHANGELOG.md @@ -1,5 +1,14 @@ ## 1.3.3-wip +* Make the zone `create*Timer` and `scheduleMicrotask` + be responsible for running callbacks in the zone they're + scheduled in, matching (new) standard zone behavior. + (The `Timer` constructors and top-level `scheduleMicrotask` + used to bind their callback, but now only registers it, + leaving the zone to run in the correct zone and handle errors.) +* Make periodic timers increment their `tick` by more than one + if `elapseBlocking` advanced time past multiple ticks. + ## 1.3.2 * Require Dart 3.3 @@ -7,7 +16,6 @@ the callback of a periodic timer would immediately invoke the same timer. * Move to `dart-lang/test` monorepo. -* Require Dart 3.5. ## 1.3.1 diff --git a/pkgs/fake_async/lib/fake_async.dart b/pkgs/fake_async/lib/fake_async.dart index b4eea856b..d16d4c9be 100644 --- a/pkgs/fake_async/lib/fake_async.dart +++ b/pkgs/fake_async/lib/fake_async.dart @@ -152,7 +152,7 @@ class FakeAsync { /// Throws an [ArgumentError] if [duration] is negative. void elapseBlocking(Duration duration) { if (duration.inMicroseconds < 0) { - throw ArgumentError('Cannot call elapse with negative duration'); + throw ArgumentError.value(duration, 'duration', 'Must not be negative'); } _elapsed += duration; @@ -178,15 +178,18 @@ class FakeAsync { /// /// Note: it's usually more convenient to use [fakeAsync] rather than creating /// a [FakeAsync] object and calling [run] manually. - T run(T Function(FakeAsync self) callback) => - runZoned(() => withClock(_clock, () => callback(this)), - zoneSpecification: ZoneSpecification( - createTimer: (_, __, ___, duration, callback) => - _createTimer(duration, callback, false), - createPeriodicTimer: (_, __, ___, duration, callback) => - _createTimer(duration, callback, true), - scheduleMicrotask: (_, __, ___, microtask) => - _microtasks.add(microtask))); + T run(T Function(FakeAsync self) callback) => runZoned( + () => withClock(_clock, () => callback(this)), + zoneSpecification: ZoneSpecification( + createTimer: (_, __, zone, duration, callback) => + _createTimer(duration, zone, callback, false), + createPeriodicTimer: (_, __, zone, duration, callback) => + _createTimer(duration, zone, callback, true), + scheduleMicrotask: (_, __, zone, microtask) => _microtasks.add(() { + zone.runGuarded(microtask); + }), + ), + ); /// Runs all pending microtasks scheduled within a call to [run] or /// [fakeAsync] until there are no more microtasks scheduled. @@ -207,9 +210,10 @@ class FakeAsync { /// The [timeout] controls how much fake time may elapse before a [StateError] /// is thrown. This ensures that a periodic timer doesn't cause this method to /// deadlock. It defaults to one hour. - void flushTimers( - {Duration timeout = const Duration(hours: 1), - bool flushPeriodicTimers = true}) { + void flushTimers({ + Duration timeout = const Duration(hours: 1), + bool flushPeriodicTimers = true, + }) { final absoluteTimeout = _elapsed + timeout; _fireTimersWhile((timer) { if (timer._nextCall > absoluteTimeout) { @@ -217,13 +221,14 @@ class FakeAsync { throw StateError('Exceeded timeout $timeout while flushing timers'); } - if (flushPeriodicTimers) return _timers.isNotEmpty; + // Always run timer if it's due. + if (timer._nextCall <= elapsed) return true; - // Continue firing timers until the only ones left are periodic *and* - // every periodic timer has had a change to run against the final - // value of [_elapsed]. - return _timers - .any((timer) => !timer.isPeriodic || timer._nextCall <= _elapsed); + // If no timers are due, continue running timers + // (and advancing time to their next due time) + // if flushing periodic timers, + // or if there is any non-periodic timer left. + return flushPeriodicTimers || _timers.any((timer) => !timer.isPeriodic); }); } @@ -234,9 +239,7 @@ class FakeAsync { /// timer fires, [_elapsed] is updated to the appropriate duration. void _fireTimersWhile(bool Function(FakeTimer timer) predicate) { flushMicrotasks(); - for (;;) { - if (_timers.isEmpty) break; - + while (_timers.isNotEmpty) { final timer = minBy(_timers, (FakeTimer timer) => timer._nextCall)!; if (!predicate(timer)) break; @@ -248,9 +251,20 @@ class FakeAsync { /// Creates a new timer controlled by `this` that fires [callback] after /// [duration] (or every [duration] if [periodic] is `true`). - Timer _createTimer(Duration duration, Function callback, bool periodic) { - final timer = FakeTimer._(duration, callback, periodic, this, - includeStackTrace: includeTimerStackTrace); + Timer _createTimer( + Duration duration, + Zone zone, + Function callback, + bool periodic, + ) { + final timer = FakeTimer._( + duration, + zone, + callback, + periodic, + this, + includeStackTrace: includeTimerStackTrace, + ); _timers.add(timer); return timer; } @@ -262,7 +276,20 @@ class FakeAsync { } /// An implementation of [Timer] that's controlled by a [FakeAsync]. +/// +/// Periodic timers attempt to be isochronous. They trigger as soon as possible +/// after a multiple of the [duration] has passed since they started, +/// independently of when prior callbacks actually ran. +/// This behavior matches VM timers. +/// +/// Repeating web timers instead reschedule themselves a [duration] after +/// their last callback ended, which shifts the timing both if a callback +/// is delayed or if it runs for a long time. In return it guarantees +/// that there is always at least [duration] between two callbacks. class FakeTimer implements Timer { + /// The zone to run the callback in. + final Zone _zone; + /// If this is periodic, the time that should elapse between firings of this /// timer. /// @@ -283,7 +310,7 @@ class FakeTimer implements Timer { /// The value of [FakeAsync._elapsed] at (or after) which this timer should be /// fired. - late Duration _nextCall; + Duration _nextCall; /// The current stack trace when this timer was created. /// @@ -302,12 +329,17 @@ class FakeTimer implements Timer { String get debugString => 'Timer (duration: $duration, periodic: $isPeriodic)' '${_creationStackTrace != null ? ', created:\n$creationStackTrace' : ''}'; - FakeTimer._(Duration duration, this._callback, this.isPeriodic, this._async, - {bool includeStackTrace = true}) - : duration = duration < Duration.zero ? Duration.zero : duration, - _creationStackTrace = includeStackTrace ? StackTrace.current : null { - _nextCall = _async._elapsed + this.duration; - } + FakeTimer._( + Duration duration, + this._zone, + this._callback, + this.isPeriodic, + this._async, { + bool includeStackTrace = true, + }) : duration = + duration < Duration.zero ? (duration = Duration.zero) : duration, + _nextCall = _async._elapsed + duration, + _creationStackTrace = includeStackTrace ? StackTrace.current : null; @override bool get isActive => _async._timers.contains(this); @@ -318,15 +350,18 @@ class FakeTimer implements Timer { /// Fires this timer's callback and updates its state as necessary. void _fire() { assert(isActive); + assert(_nextCall <= _async._elapsed); _tick++; if (isPeriodic) { _nextCall += duration; - // ignore: avoid_dynamic_calls - _callback(this); + while (_nextCall < _async._elapsed) { + _tick++; + _nextCall += duration; + } + _zone.runUnaryGuarded(_callback as void Function(Timer), this); } else { cancel(); - // ignore: avoid_dynamic_calls - _callback(); + _zone.runGuarded(_callback as void Function()); } } } diff --git a/pkgs/fake_async/test/fake_async_test.dart b/pkgs/fake_async/test/fake_async_test.dart index 463eecdb8..8a22d39fc 100644 --- a/pkgs/fake_async/test/fake_async_test.dart +++ b/pkgs/fake_async/test/fake_async_test.dart @@ -27,9 +27,18 @@ void main() { }); group('elapseBlocking', () { - test('should elapse time without calling timers', () { - Timer(elapseBy ~/ 2, neverCalled); - FakeAsync().elapseBlocking(elapseBy); + test('should elapse time without calling timers or microtasks', () { + FakeAsync() + ..run((_) { + // Do not use [neverCalled] from package:test. + // It schedules timers to "pump the event loop", + // which stalls the test if you don't call `elapse` or `flushTimers`. + final notCalled = expectAsync0(count: 0, () {}); + scheduleMicrotask(notCalled); + Timer(elapseBy ~/ 2, notCalled); + Timer(Duration.zero, expectAsync0(count: 0, notCalled)); + }) + ..elapseBlocking(elapseBy); }); test('should elapse time by the specified amount', () { @@ -270,7 +279,7 @@ void main() { // TODO: Pausing and resuming the timeout Stream doesn't work since // it uses `new Stopwatch()`. // - // See https://code.google.com/p/dart/issues/detail?id=18149 + // See https://dartbug.com/18149 test('should work with Stream.periodic', () { FakeAsync().run((async) { expect(Stream.periodic(const Duration(minutes: 1), (i) => i), @@ -603,6 +612,94 @@ void main() { expect(log, ['periodic 1', 'single']); }); }); + + test('can increment periodic timer tick by more than one', () { + final async = FakeAsync(); + final ticks = <(int ms, int tick)>[]; + final timer = async + .run((_) => Timer.periodic(const Duration(milliseconds: 1000), (t) { + final tick = t.tick; + ticks.add((async.elapsed.inMilliseconds, tick)); + })); + expect(timer.tick, 0); + expect(ticks, isEmpty); + + // Run timer once. + async.elapse(const Duration(milliseconds: 1000)); + expect(ticks, [(1000, 1)]); + expect(timer.tick, 1); + expect(async.elapsed, const Duration(milliseconds: 1000)); + ticks.clear(); + + // Block past two timer ticks without running events. + async.elapseBlocking(const Duration(milliseconds: 2300)); + expect(async.elapsed, const Duration(milliseconds: 3300)); + // Runs no events. + expect(ticks, isEmpty); + + // Run due timers only. Time does not advance. + async.flushTimers(flushPeriodicTimers: false); + expect(ticks, [(3300, 3)]); // Timer ran only once. + expect(timer.tick, 3); + expect(async.elapsed, const Duration(milliseconds: 3300)); + ticks.clear(); + + // Pass more time, but without reaching tick 4. + async.elapse(const Duration(milliseconds: 300)); + expect(ticks, isEmpty); + expect(timer.tick, 3); + expect(async.elapsed, const Duration(milliseconds: 3600)); + + // Pass next timer. + async.elapse(const Duration(milliseconds: 500)); + expect(ticks, [(4000, 4)]); + expect(timer.tick, 4); + expect(async.elapsed, const Duration(milliseconds: 4100)); + }); + + test('can run elapseBlocking during periodic timer callback', () { + final async = FakeAsync(); + final ticks = <(int ms, int tick)>[]; + final timer = async + .run((_) => Timer.periodic(const Duration(milliseconds: 1000), (t) { + ticks.add((async.elapsed.inMilliseconds, t.tick)); + if (t.tick == 2) { + async.elapseBlocking(const Duration(milliseconds: 2300)); + // Log time at end of callback as well. + ticks.add((async.elapsed.inMilliseconds, t.tick)); + } + })); + expect(timer.tick, 0); + expect(ticks, isEmpty); + + // Run timer once. + async.elapse(const Duration(milliseconds: 1100)); + expect(ticks, [(1000, 1)]); + expect(timer.tick, 1); // Didn't tick yet. + expect(async.elapsed, const Duration(milliseconds: 1100)); + ticks.clear(); + + // Run timer once more. + // This blocks for additional 2300 ms, making timer due again, + // and `flushTimers` will run it. + async.elapse(const Duration(milliseconds: 1100)); + expect(ticks, [(2000, 2), (4300, 2), (4300, 4)]); + expect(timer.tick, 4); + expect(async.elapsed, const Duration(milliseconds: 4300)); + ticks.clear(); + + // Pass more time, but without reaching tick 5. + async.elapse(const Duration(milliseconds: 300)); + expect(ticks, isEmpty); + expect(timer.tick, 4); + expect(async.elapsed, const Duration(milliseconds: 4600)); + + // Pass next timer normally. + async.elapse(const Duration(milliseconds: 500)); + expect(ticks, [(5000, 5)]); + expect(timer.tick, 5); + expect(async.elapsed, const Duration(milliseconds: 5100)); + }); }); group('clock', () { @@ -653,6 +750,121 @@ void main() { }); }); }); + + group('zone', () { + test('can be used directly', () { + final async = FakeAsync(); + final zone = async.run((_) => Zone.current); + final log = []; + zone + ..scheduleMicrotask(() { + log.add('microtask'); + }) + ..createPeriodicTimer(elapseBy, (_) { + log.add('periodicTimer'); + }) + ..createTimer(elapseBy, () { + log.add('timer'); + }); + expect(log, isEmpty); + async.elapse(elapseBy); + expect(log, ['microtask', 'periodicTimer', 'timer']); + }); + + test('runs in outer zone, passes run/register/error through', () { + var counter = 0; + final log = []; + final (async, zone) = runZoned( + () => fakeAsync((newAsync) { + return (newAsync, Zone.current); + }), + zoneSpecification: + ZoneSpecification(registerCallback: (s, p, z, f) { + final id = ++counter; + log.add('r0(#$id)'); + f = p.registerCallback(z, f); + return () { + log.add('#$id()'); + return f(); + }; + }, registerUnaryCallback: (s, p, z, f) { + final id = ++counter; + log.add('r1(#$id)'); + f = p.registerUnaryCallback(z, f); + return (v) { + log.add('#$id(_)'); + return f(v); + }; + }, run: (s, p, z, f) { + log.add('run0'); + return p.run(z, f); + }, runUnary: (s, p, z, f, v) { + log.add('run1'); + return p.runUnary(z, f, v); + }, handleUncaughtError: (s, p, z, e, _) { + log.add('ERR($e)'); + })); + + zone.run(() { + log.clear(); // Forget everything until here. + scheduleMicrotask(() {}); + }); + expect(log, ['r0(#1)']); + + zone.run(() { + log.clear(); + Timer(elapseBy, () {}); + }); + expect(log, ['r0(#2)']); + + zone.run(() { + log.clear(); + Timer.periodic(elapseBy * 2, (t) { + if (t.tick == 2) { + throw 'periodic timer error'; // ignore: only_throw_errors + } + }); + }); + expect(log, ['r1(#3)']); + log.clear(); + + async.flushMicrotasks(); + // Some zone implementations may introduce extra `run` calls. + expect(log.tail(2), ['run0', '#1()']); + log.clear(); + + async.elapse(elapseBy); + expect(log.tail(2), ['run0', '#2()']); + log.clear(); + + async.elapse(elapseBy); + expect(log.tail(2), ['run1', '#3(_)']); + + zone.run(() { + log.clear(); + scheduleMicrotask(() { + throw 'microtask error'; // ignore: only_throw_errors + }); + Timer(elapseBy, () { + throw 'timer error'; // ignore: only_throw_errors + }); + }); + expect(log, ['r0(#4)', 'r0(#5)']); + log.clear(); + + async.flushMicrotasks(); + expect(log.tail(3), ['run0', '#4()', 'ERR(microtask error)']); + log.clear(); + + async.elapse(elapseBy); + expect(log.tail(3), ['run0', '#5()', 'ERR(timer error)']); + log.clear(); + + async.elapse(elapseBy); + expect(log.tail(3), ['run1', '#3(_)', 'ERR(periodic timer error)']); + log.clear(); + }); + }); } /// Returns a matcher that asserts that a [DateTime] is within 100ms of @@ -661,3 +873,7 @@ Matcher _closeToTime(DateTime expected) => predicate( (actual) => expected.difference(actual as DateTime).inMilliseconds.abs() < 100, 'is close to $expected'); + +extension on List { + List tail(int count) => sublist(length - count); +}