dart-lang / fake_async

Fake asynchronous events for deterministic testing.
https://pub.dev/packages/fake_async
Apache License 2.0
90 stars 16 forks source link

FakeAsync hangs when operating on a Stream #65

Open eximius313 opened 1 year ago

eximius313 commented 1 year ago

This test:

        test('Should interrupt on timeout', () {
            //given
            var controller = StreamController<Uint8List>();
            const expectedError = 'No response';

            //when
            result() {
              final comleter = Completer<bool>();
              controller.stream.timeout(
                Duration(seconds: 5),
                onTimeout: (sink) {
                  print('on timeout!');
                  sink.close();
                },
              ).listen((event) {
              }, onDone: () {
                print('onDone');
                comleter.completeError(Exception(expectedError));
              });
              return comleter.future;
            }

            //then
            expect(result, throwsA(predicate((e) => e is Exception && e.toString() == 'Exception: $expectedError')));
        });

passes as expected after 5 seconds. But when I apply FakeAsync:

        test('Should interrupt on timeout', () {
          fakeAsync((async) {
            //given
            var controller = StreamController<Uint8List>();
            const expectedError = 'No response';

            //when
            result() {
              final comleter = Completer<bool>();
              controller.stream.timeout(
                Duration(seconds: 5),
                onTimeout: (sink) {
                  print('on timeout!');
                  sink.close();
                },
              ).listen((event) {
              }, onDone: () {
                print('onDone');
                comleter.completeError(Exception(expectedError));
              });
              return comleter.future;
            }

            //then
            expect(result, throwsA(predicate((e) => e is Exception && e.toString() == 'Exception: $expectedError')));

            async.elapse(Duration(seconds: 6));
          });
        });

it displays:

on timeout!
onDone

as expeceted, but then test hangs and is interrupted after 30sec by timeout:

00:31 +0 -1: Should interrupt on timeout [E]
  TimeoutException after 0:00:30.000000: Test timed out after 30 seconds. See https://pub.dev/packages/test#timeouts
  dart:isolate  _RawReceivePort._handleMessage
lrhn commented 7 months ago

Try moving the final comleter = Completer<bool>(); outside of the fakeAsync zone.

It's hard to reason about fakeAsync, but my experience is that you should never rely on anything created inside the fake-async zone to happen, or not happen, unless you are actively elapsing time. Don't trust that anything can cross the zone boundary by itself, because that requires something driving the internal progress.

So, if possible, I try to make sure elapsing is done by code running outside of the fake-async zone (because otherwise we can get a deadlock when nothing elapses to the microtask which should run the async.elapse(...), and try to push results outside of the zone as well, to check them there. (But I don't have a pattern that I know will always work.)

eximius313 commented 7 months ago

thanks @lrhn, this code:

void main() {
  test('Should interrupt on timeout', () {
    final completer = Completer<bool>();
    fakeAsync((async) {
      //given
      var controller = StreamController<Uint8List>();
      const expectedError = 'No response';

      //when
      result() {
        controller.stream.timeout(
          Duration(seconds: 5),
          onTimeout: (sink) {
            print('on timeout!');
            sink.close();
          },
        ).listen((event) {
        }, onDone: () {
          print('onDone');
          completer.completeError(Exception(expectedError));
        });
        return completer.future;
      }

      //then
      expect(result, throwsA(predicate((e) => e is Exception && e.toString() == 'Exception: $expectedError')));

      async.elapse(Duration(seconds: 6));
    });
  });
}

indeed works - although I have no idea why, because for me:

  1. Code inside fakeAsync((async) { was executed, because 'onDone' was printed on the console
  2. it is the completer.completeError(Exception(expectedError)); who should end the future so what's creation of final completer = Completer<bool>(); has to do with it?

Kind regards