-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Sometimes it is impossible to control async work with zones, because some values are tied to root zone #40131
Comments
cc @lrhn |
This is a thorny area of the async behavior. Futures completing doesn't necessarily use the microtask at all. One future completing can easily cascade into a number of other futures completing without going back to the microtask queue. As such, intercepting the microtask queue isn't guranteed to prevent futures from completing. It's also a hard to figure out which zone a microtask is scheduled in, and which it should be scheduled in. If a future completed in zone A, and it has a listener which expects to run in zone B, should the future schedule itself in zone A or zone B (or the root zone, for good measure)? Picking the listener is tricky, because if the future has two listeners from different zones, B and C, which one should it then schedule itself in? It only needs one schedule because it can, and does, complete both listeners in the same go. SO the choice between listeners is guaranteed to be arbitrary. That actually suggests to use the future's own (A) zone. However, there is no reason for a future to retain its zone after it has completed with a value, and I'd like to avoid that, so maybe using the A zone isn't the best choice anyway. I'm willing to use the root zone here, which definitely means that you won't be able to prevent the microtask. Currently I believe we always use the A zone. I am willing to look at this (read: I have been looking at it), but I haven't found a clearly superior approach yet. tl;dr: Don't expect intercepting the microtask queue to allow you to control futures. It may or it might, and which one it is will depend on the accidental ordering of events. |
@lrhn, thank you for your response! Still, I have some additional thoughts on this topic. Although I understood the reasons for scheduling once and in the Future's own zone, it is still a very confusing behavior for users. The simplest found way to fix an issue is to wrap resolved Future with import 'dart:async';
import 'package:test/test.dart';
void noop([Object _]) {}
void main() {
group('Future should', () {
int schedulesCount;
Future<int> resolvedExternalFuture;
final externalZone = Zone.current;
final handledZone = Zone.current.fork(
specification: ZoneSpecification(
scheduleMicrotask: (source, parent, zone, f) {
schedulesCount += 1;
parent.scheduleMicrotask(zone, noop);
f();
},
),
);
setUp(() {
schedulesCount = 0;
externalZone.run(() {
resolvedExternalFuture = Future.sync(() => null);
});
});
test('schedule microtasks in handled zone', () {
handledZone.run(() {
// This will be registered in handled zone but will be scheduled
// in external zone which is confusing and will not increment counter
resolvedExternalFuture.whenComplete(() {
print('external: scheduled');
});
// This is a workaround to force the callback to be scheduled
// within handled zone
Future.value(resolvedExternalFuture).whenComplete(() {
print('workaround: scheduled');
});
});
expect(schedulesCount, 1);
});
});
} The response will be:
However, this can't be used, when the problem begins and happens in external code and before I can somehow influence it. This leads me to think that the root of the problem is the Future static constants. In the case of the first test of thread - The IMO, something like |
Not using a reusable |
It is true that the
The first property seems to hold in This is very surprising given that We use this to implement a number of useful features:
It is therefore very surprising to see |
Here are some bug reports associated with this issue: google/quiver-dart#583 |
I don't think we've ever promised that "The receiver of an async callback schedules microtasks and timers in the current zone." For callbacks of For A stream will "run" in the zone where the The issue here seems to be that the We can try that, but it is very likely that a lot of other unstable code will break then. |
It doesn't have to be a promise, but the default. If nothing in the documentation explicitly states that an API is shifting zones then the expectation is that it won't move my code to another zone. Any other behavior leads to surprises. After all, the So far the vast majority of core libraries has been well-behaved. This instance is one of the rare exceptions, which makes it so surprising.
All code runs in some zone, if anything, the root zone. Consider a block of Dart code (a series of statements). Whatever zone the first statement ends up running in (the "current zone"), then, by default, all other statements should run in the same zone, unless there's something that very clearly shifts the zone. In the example above the statements inside print(Zone.current.hashCode);
Stream<Object> // zone 1
.periodic(const Duration(hours: 1)) // zone 1
.take(1) // zone 1
.listen( // zone 1
(_) {
print(Zone.current.hashCode);
print('onData'); // zone 1
testCounter += 1; // zone 1
},
onDone: () {
print(Zone.current.hashCode);
print('onDone'); // zone 2!!!
testCounter += 1; // zone 2!!!
},
);
// zone 1 This is unexpected because there's nothing about producing a periodic stream of events that implies zone changes. It's a pure in-memory computation that relies on microtasks and timers, all of which are interceptable by zones. Edit: added |
We definitely do promise that the callbacks to The I honestly don't understand the connection between a Everything else is less specified. We do not guarantee where the implementation runs, which does affect any microtasks it might schedule. |
This is both surprising and undocumented. Surprising, because what would the author of the
I don't think
It is actually fine for the implementation to run in a different zone, as long as it has a good reason to do so and has good docs. Stream's Microtasks and timers are special, because |
I admit that the zone interaction with streams and futures is almost entirely undocumented. The one thing that you can depend on is that only callbacks which are registered in a zone will also be called in a specific zone. That's the All other callbacks are not zone aware, and will be called in whatever zone is current at the time they are triggered. These are control callbacks for streams ( Making it possible to intercept microtasks/timers/etc. in a somewhat predictable way is actually why the stream controller's Zones are still a hot mess in many other ways, but I think that particular choice is reasonable. It does mean that if you move a stream subscription into a different zone and add listeners there, the code providing events and the code consuming events will no longer be in the same zone, because setting a new listener with One big confuser here is that a The other issue is that sometimes the future implementation needs to schedule a microtask. It has so far consistently used the zone that the future was created in. It is a change, and since people keep depending on the behavior we have, whether promised or not, it could be a breaking change. (I've wanted to change |
Hi is there any updates? This indeed makes fake_async quite useless... |
No new plans. The The constant |
I don't know how important the use of the |
The It does that in the root zone, which was actually not always true. It used to be created lazily in whichever zone first accessed it. Using any one zone is a problem, because it is returned from code running on other zones than the root zone. Changing that to, fx, a null future per zone now breaks bad code that relies on that future being in the root zone. If we can find and fix that bad code, somehow, maybe we can fix it. |
Can you show me an example of such bad code? I'm interested to learn more. |
We have not tracked it down to a minimal reproduction case. |
I think I remember one example using |
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):
It is expected that the test should pass, but it fails:
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 withnull
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 withscheduleMicrotask
from the zone of the resolved Future, which is the root zone. And here we lost control overonDone
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.
The text was updated successfully, but these errors were encountered: