Closing a StreamController in addTearDown freezes test
Using a StreamController with an addTearDown to close the stream within withClock freezes the test.
If I remove the addTeardown the test finishes.
Here is a small example:
testWidgets('description', (tester) async {
await withClock(Clock.fixed(DateTime(2025)), () async {
final controller = StreamController<DateTime?>();
addTearDown(() async {
print('Before');
await controller.close();
print('After');
});
final widget = MaterialApp(home: Text(clock.now().toString()));
await tester.pumpWidget(widget);
expect(find.text('2025-01-01 00:00:00.000'), findsOneWidget);
print('End');
});
});
Output:
End
Before
An await controller.close() won't complete until the event is delivered. It won't be delivered until someone listens to the stream. In this test, nobody listens to the stream, so "asynchronously hanging" at the awit controller.close(); is expected.
For a plain example:
import "dart:async";
void main() async {
// Keep-alive for five seconds.
Timer.periodic(const Duration(seconds: 1), (t) {
print("tick");
if (t.tick == 5) t.cancel();
});
var controller = StreamController();
print("Closing");
await controller.close();
print("Done");
}
This program, run with plain dart run, will never complete the close() call.
It will exit after five seconds, when there is nothing else to keep the program alive.
In this example, I guess something is actively keeping the program alive until the addTearDown callback has completed, which it never does.
It's not related to clock. If I reduce the code to
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
print("main");
testWidgets('description', (tester) async {
print("testWidget body start");
final controller = StreamController<DateTime?>();
addTearDown(() async {
print("addTearDown body start");
await controller.close();
print("addTearDown body end");
});
final widget = MaterialApp(home: Text("Banana"));
await tester.pumpWidget(widget);
expect(find.text('Banana'), findsOneWidget);
print("testWidget body end");
});
}
it still hangs at the await controller.close().
So the only questions here are really:
- Why call
closeon a controller that hasn't been listened to? (And don't!) - What is keeping the test program alive after all computation has ceased?
The clock package is not relevant to the issue.
I also looked at which zones were in play. An instrumented version of the original example, where it gives numbers to zones and shows the current zone and its parent zone chain at various points:
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:clock/clock.dart';
void main() {
print("main: $zid");
testWidgets('description', (tester) async {
print("testWidget body: $zid");
await withClock(Clock.fixed(DateTime(2025)), () async {
print("withClock body: $zid");
final controller = StreamController<DateTime?>();
addTearDown(() async {
print('Before');
print("addTearDown body: $zid");
await controller.close();
print('After');
print("addTearDown body: $zid");
});
final widget = MaterialApp(home: Text(clock.now().toString()));
await tester.pumpWidget(widget);
expect(find.text('2025-01-01 00:00:00.000'), findsOneWidget);
print('test end: $zid');
});
});
}
String get zid =>
"Zone:${[for (Zone? z = Zone.current; z != null; z = z.parent) z].reversed.map((z) => _zoneId[z] ??= _ids++).join(">")}";
Map<Object?, int> _zoneId = HashMap.identity();
int _ids = 0;
This shows a curious output:
00:01 +0: loading /usr/local/google/home/lrn/flutter/pkg/testpkg/test/widget_test.dart
main: Zone:0>1>2>3
00:02 +0: description
testWidget body: Zone:0>1>2>4>5>6>7>8>9>10>11>12>13
withClock body: Zone:0>1>2>4>5>6>7>8>9>10>11>12>13>14
test end: Zone:0>1>2>4>5>6>7>8>9>10>11>12>13>14
Before
addTearDown body: Zone:0>1>2>4>5>6>7>8>15>16>17
(after which it hangs as usual).
The main method is run in a zone with three parent zones, so three zones below the root. I guess the test framework has some context.
The testWidget main is run in a zone which drops the last zone of the main zone. That's a bad idea, it should be building on top of the zone it's being run in.
It also adds 10 zones on top. I don't know what they do, and having many zones shoudn't be a problem, but it suggests that a lot of things are going on. (If they also exist in production code, then it's good that it's testing in the same environment. If not, it might be bad that it's testing in an environment that's too far from production.)
The withClock adds just one more zone, as expected.
Then the addTearDown body throws away a number of zones from where it's called, including the withClock zone. It means that even if he addTearDown is called inside the withClock zone, it doesn't have access to that clock.
That's still bad. It should only be adding new zones on top, never throwing zones away.
(The plain package:test addTearDown function has the same issue, it's probably even the same function. It throws away some of the zones of the test function callback's zone, but not all of them, and it throws away any nested zones added inside. That is still a bad idea, but doesn't come from the Flutter framework.)
So, something is iffy with the ways zones are stored, used, and sometimes ignored. I don't think it's the root of this issue, but I wouldn't trust using zones in a framework like this. (And that is why the framework should work with the zones added by users, so the users can trust what they see.)