Skip to content

Sometimes it is impossible to control async work with zones, because some values are tied to root zone #40131

Open
@dmitryelagin

Description

@dmitryelagin

In some cases, the control over the Dart zone can be limited when we use Future or Stream because some inner constants are tied to the root zone.

For example (inspired by FakeAsync from Quiver):

import 'dart:async';

import 'package:test/test.dart';

void noop([Object _]) {}

void main() {
  group('Counter should', () {
    List<void Function()> actions;

    int testCounter;
    int callbacksCount;
    int periodicCallbacksCount;
    int microtasksCount;

    // Create zone specification that just saves all callbacks
    // for timers and microtasks and then does nothing
    final zoneSpecification = ZoneSpecification(
      createTimer:
        (source, parent, zone, duration, f) {
          callbacksCount += 1;
          actions.add(f);
          return parent.createTimer(zone, duration, noop);
        },

      createPeriodicTimer:
        (source, parent, zone, period, f) {
          periodicCallbacksCount += 1;
          actions.add(() => f(null));
          return parent.createPeriodicTimer(zone, period, noop);
        },

      scheduleMicrotask:
        (source, parent, zone, f) {
          microtasksCount += 1;
          actions.add(f);
          parent.scheduleMicrotask(zone, noop);
        },
    );

    setUp(() {
      actions = [];
      testCounter = 0;
      callbacksCount = 0;
      periodicCallbacksCount = 0;
      microtasksCount = 0;
    });

    test('be incremented two times', () {
      runZoned(
        () {
          // Do some async work to register some callbacks
          Stream<Object>
            .periodic(const Duration(hours: 1))
            .take(1)
            .listen(
              (_) {
                print('onData');
                testCounter += 1;
              },
              onDone: () {
                print('onDone');
                testCounter += 1;
              },
            );

          // Call all registered callbacks syncroniously until there
          // will be no new scheduled callbacks
          while (actions.isNotEmpty) {
            final fns = List.of(actions);
            actions.clear();
            fns.forEach((fn) => fn());
          }

          // Check registrations count
          print('createTimer: $callbacksCount');
          print('createPeriodicTimer: $periodicCallbacksCount');
          print('scheduleMicrotask: $microtasksCount');

          // Test counter
          expect(testCounter, 2);
        },
        zoneSpecification: zoneSpecification,
      );
    });
  });
}

It is expected that the test should pass, but it fails:

onData
createTimer: 0
createPeriodicTimer: 1
scheduleMicrotask: 0
Expected: <2>
  Actual: <1>

package:test_api           expect
test/dart_test.dart 81:11  main.<fn>.<fn>.<fn>
dart:async                 runZoned
test/dart_test.dart 50:7   main.<fn>.<fn>

onDone
✖ Counter should be incremented two times
Exited (1)

We found this on Dart SDK 2.4.0, it also can be reproduced with Dart SDK 2.7.0.

Before it fails, .take(1) closes stream which cancels the subscription. In the current example, cancellation returns Future which is resolved with null in the root zone. It is constant.
https://github.com/dart-lang/sdk/blob/master/sdk/lib/async/future.dart#L151
The onDone callback will be called after cancellation Future resolving, but it is already resolved. So, it will be called with scheduleMicrotask from the zone of the resolved Future, which is the root zone. And here we lost control over onDone and why the test failed.

Unfortunately, this is a very popular fail case in tests, where Quiver, RxDart and other Stream-heavy libraries are used and when we try to test something synchronously.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-core-librarySDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries.library-asynctype-enhancementA request for a change that isn't a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions