-
Notifications
You must be signed in to change notification settings - Fork 6k
Refactor platform message logic #21572
Conversation
I'm expecting tests to fail, I didn't run them locally. |
40ac949
to
efcfc04
Compare
Fixes flutter/flutter#66699. (Well, it doesn't really fix it, it lays the groundwork for a replacement approach. But it's related to it.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good for the most part the biggest points I have are:
- The behavior of clearing the listener while draining
- Never drop messages without telling the developer, bugs created by this are difficult to debug
- An explicit call to drain gives us a place to put logic that happens after the drain
// the buffer once that is available to users and print in all engine builds | ||
// after we verify that dropping messages isn't part of normal execution. | ||
_printDebug('Overflow on channel: $channel. ' | ||
'Messages on this channel are being discarded in FIFO fashion. ' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can't get rid of this warning. We can't just drop messages without warning developers. It's particularly an issue with MethodChannels that multiplex multiple messages onto one channel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding is that dropping messages here is part of the expected behaviour of the API. Is that not correct? The documentation talks about how you can legitimately want to only be holding on to the newest message (e.g. because you're a level-trigger API).
We have a policy of only logging messages that are actionable (see style guide). If the messages are not actionable, we shouldn't log the message. I don't know what action one should take with this message.
I agree that if you're dropping messages when you don't expect to be (e.g. you're listening to the wrong channel) then this message would make debugging the issue much easier. Is there some way we can determine when the message is actionable and when it's just logspam?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, it's not expected. Running the engine is an asynchronous operation and we don't provide any mechanism for users to determine if the engine is running before we can send messages.
This code will fail to deliver a message randomly:
[engine run];
usleep(rand() * 1000000);
[channel sendMessage:@"hello"];
[channel sendMessage:@"goodbye"];
One action that could fix the problem is to chose a more appropriate buffer size, which is mentioned. If you are using method channels you'd probably want to base that calculus off of the number of unique messages you are sending over that channel.
It also mentions that the engine isn't running which admittedly is a hint about the asynchronous nature of running engines. With that information they could solve the problem a few other ways.
When I first implemented channel buffers we saw there were all kinds of messages routinely being dropped during the initialization of an engine. They were not expected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, we should fix the documentation then, because right now it clearly states that there are cases where dropping messages is the appropriate behavior.
Maybe we should show the message for any message that is buffered, if the buffer size hasn't been set? I guess in that case we probably would want to default to a buffer size of zero, too, since we'd be saying all plugins need to set their buffer size if they send messages before the framework is ready.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another option would be to leave the default as now, but have a specific control message to silence the message (and have the warning log say to send this control message).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you point out the documentation that says dropping messages is appropriate? One other caveat we haven't discussed is that there are 2 types of channels and the semantics aren't explicit, channels where dropping messages is fine (think sending the gyro readings to the engine) and channels where it isn't (think sending entries in a database). In other words, is the message interpreted as set
or add
? Is the documentation referring to that?
Buffer size zero by default would be a big breaking change. I wouldn't recommend that.
The worse case scenario is that users are losing data when they don't expect it. The print statement is meant to warn users of that scenario. I worry moving the print statement out of that scenario may cause it to be spammy and less likely heeded when it is appropriate.
However we slice things, not printing out the error despite having set the buffer size wouldn't be ideal since you'd have no idea that you may have chosen poorly for your use case.
since we'd be saying all plugins need to set their buffer size if they send messages before the framework is ready.
Remember there is no way to know when the framework is ready clients can't meaningfully know that they may be doing something that may not work.
have a specific control message to silence the message
That's cool with me. A bigger change would be if we could know the semantics of the channel and only print out the error if it has add
semantics instead of set
semantics. The ship might have sailed on that though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://master-api.flutter.dev/flutter/dart-ui/ChannelBuffers/kDefaultBufferSize-constant.html is the documentation I was talking about (I expand on it in this PR). It says "Size 1 implies that you only care about the most recent value." and says size 1 is the default, which implies that dropping messages is fine by default.
We could improve the control channel API in general, making it support a way to specify if it's being used for an event sequence (where dropping is bad) or where it's being used as a level-trigger (where dropping is fine). That would also give us the opportunity to make it use the standard binary format instead of its current inefficent bespoke text format with '\r' as the separator. Let me see what I can do.
} | ||
} | ||
|
||
/// Invokes [callback] inside the given [zone] passing it [arg1] and [arg2]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I'd note arity 2 inside the docstring to immediately communicate the meaning of 2 in the name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added
/// | ||
/// When there is no listener, messages are queued, up to [capacity], | ||
/// and then discarded in a first-in-first-out fashion. | ||
void clearListener() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clearing the listener while draining will lead to a null pointer exception. I thought we said we would drop the messages.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will add a test for this case.
My intention was for removing a listener to just cause the messages to stop being drained, without losing them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test seems to confirm that clearListener() just pauses the draining.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh yep, thanks I missed the null check before its usage.
/// The messages are processed by calling the given `callback`. Each message | ||
/// is processed in its own microtask. | ||
// TODO(ianh): deprecate once framework uses [setListener]. | ||
Future<void> drain(String name, DrainChannelCallback callback) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure that anyone ever took advantage of the future that was returned here, but now there is no way for a client to know when the draining process is done with the setListener API. There isn't a place to put it either. I would rather keep the explicit drain call, maybe replace this with _drainAsynchronously
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd just say launching any asynchronous operation without returning a handle to that operation is a red flag since it hides the fact that something is asynchronous and provides no mechanism for someone to synchronize if they want to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now the drain API only has one client, the framework, and it ignores the future.
That said, I think that exposing the end of the drain is a dangerous affordance. The point of this API is to buffer the messages so that when the plugin is ready to receive them, it gets them. The sender has no way to know if the plugin is already listening or not when it sends the message, so by definition the messages it sends cannot depend on whether or not the plugin is listeneing. If we allow messages received before the plugin subscribes to be distinguished to those received after the plugin subscribes, we allow the plugin to behave differently for events sent before and after subscription, but since we just said the sender is not distinguishing, that implies that we are allowing behavior variance along an axis that should have none (i.e., it would be a bug vector).
It also would encourage race conditions. Consider these two cases:
A:
- t=0ms plugin requests buffer size 3
- t=0ms message 1
- t=2ms message 2
- t=3ms plugin subscribes
- t=4ms message 3
B:
- t=0ms plugin requests buffer size 3
- t=0ms message 1
- t=1ms plugin subscribes
- t=2ms message 2
- t=4ms message 3
Why would we want the plugin to react differently to message 2 in the first case vs the second case?
To put it another way, what's the use case for knowing when the drain is complete?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I admit it is hypothetical. Hiding an asynchronous operation in a synchronous signature can cause bugs. It's not as helpful in Dart though because Dart allows you to drop return values implicitly.
Someday someone might write this code:
_drain();
clearListener();
This is a nonsensical operation because the clearListener would immediately disable the drain before anything happens.
If _drain
advertises itself as an asynchronous operation by returning a Future<void>
that mistake is less likely. I don't know what code some future developer might write but I'd error on the side of giving them best hope of writing correct code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for not being clear. My medium term plan is to remove the drain API, it just becomes an implicit behavior of setListener. The idea is to remove the concept of draining from the API -- it just behaves exactly as if messages were only sent while the listener was set, and they get processed the same as if there were messages in fact only being sent while the listener was set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, I got that. I think it's a worse design because it's hiding asynchronous logic inside of the setter. This is hiding side effects and temporal dependencies which is a liability. If you are keen on this design naming setListener
to setListenerAndDrain
would be an improvement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really understand the concern here. Can you elaborate?
The point of this design is to make the draining entirely transparent, indistinguishable from not draining. There should be no way to distinguish someone connecting to a plugin before the plugin sends messages from connecting after the plugin sends messages. There's no "draining" anymore, conceptually, it's just that the messages weren't delivered until there was a listener.
lib/ui/channel_buffers.dart
Outdated
/// See [drain] for more details. | ||
void _drainStep() { | ||
assert(_draining); | ||
if (_queue.isNotEmpty && _channelCallbackRecord != null) { | ||
final _StoredMessage message = pop(); | ||
_channelCallbackRecord!.invoke(message.data, message.callback); | ||
scheduleMicrotask(_drainStep); | ||
} else { | ||
_draining = false; | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The previous implementation of drain would handle the messages serially, this will potentially parallelize asynchronous operations. I think that is good since that behavior will match channels when they aren't buffering, correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The callbacks are synchronous, so they are guaranteed to be complete before the next microtask is scheduled. This matches the way messages are handled outside of drain.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, they might spawn asynchronous operations but I think it was erroneous to wait for those operations previously. This sounds good to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lib/ui/channel_buffers.dart
Outdated
|
||
_RingBuffer(this._capacity) | ||
: _queue = collection.ListQueue<T>(_capacity); | ||
/// The underlying data for the RingBuffer. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does "RingBuffer" refer to? This PR seems to be removing this class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
lib/ui/channel_buffers.dart
Outdated
/// messages are discarded until the capacity is reached. | ||
/// | ||
/// Returns the number of discarded messages resulting from resize. | ||
int resize(int newSize) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not set capacity
? (a setter would remove the need to refer to capacity using two terms, "capacity" and "size").
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
lib/ui/channel_buffers.dart
Outdated
: _queue = collection.ListQueue<T>(_capacity); | ||
/// The underlying data for the RingBuffer. | ||
/// | ||
/// [ListQueue]s dynamically resize, but [_Channel]s do not. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does "resize" mean in this sentence? _Channel
do seem to resize (this PR even introduces the resize
method).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed this sentence
lib/ui/channel_buffers.dart
Outdated
/// If the new size is smaller than the [length], the oldest | ||
/// messages are discarded until the capacity is reached. | ||
/// | ||
/// Returns the number of discarded messages resulting from resize. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a native English speaker, but I feel like there should be either "a" or "the" in front of "resize".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is now a setter, this line has been removed.
lib/ui/channel_buffers.dart
Outdated
typedef DrainChannelCallback = Future<void> Function(ByteData?, PlatformMessageResponseCallback); | ||
/// Drains a single message and then reinvokes itself asynchronously. | ||
/// | ||
/// See [drain] for more details. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/drain/_drain/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
lib/ui/channel_buffers.dart
Outdated
/// A mapping between a channel name and its associated [_Channel]. | ||
final Map<String, _Channel> _channels = <String, _Channel>{}; | ||
|
||
/// Adds a message (`data`) to the named channel buffer (`channel`). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you mean name
instead of channel
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
/// ## Draining | ||
/// | ||
/// If any messages were queued before the listener is added, | ||
/// they are drained asynchronously after this method returns. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider linking to the drain
method at the end of the paragraph. A reader may not know what "drain" means.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
/// | ||
/// The messages are processed by calling the given `callback`. Each message | ||
/// is processed in its own microtask. | ||
// TODO(ianh): deprecate once framework uses [setListener]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this method is going away we should explain the term "drain" in other dartdocs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs for setListener have a section on this.
lib/ui/hooks.dart
Outdated
|
||
/// Invokes [callback] inside the given [zone] passing it [arg]. | ||
/// | ||
/// The 1 in the name refers to the number of arguments expected by the callback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...as well as the number of arguments passed to this function, not counting callback
and zone
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added
try { | ||
channelBuffers.handleMessage(data!); | ||
} catch (ex) { | ||
print('Message to "$name" caused exception $ex'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably log to https://api.dart.dev/stable/2.10.1/dart-html/Console/error.html, and should also include the stack trace.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I was wondering about this. Maybe we shouldn't even catch the exception here, and let the usual uncaught exception logic do its thing? I've removed the catch entirely for now. cc @gaaclarke who added this catch originally.
// found in the LICENSE file. | ||
|
||
// @dart = 2.6 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We currently do not run these tests against the web version and instead maintain separate tests. Can you please copy this test into lib/web_ui/test?
/cc @nturgut how feasible is it to run these tests against the web version? One difference between these tests and ours is that these tests input dart:ui
, while web tests import package:ui/ui.dart
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had actually already ported the tests but forgot to git add
them, oops.
0c616e0
to
4cbd2ad
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changes related to keyboard, history, text and text editing look good to me. The rest was covered by @yjbanov's review I believe.
our integration tests will fail if text_editing breaks :) They are testing almost every command that passes through platform channels. |
import 'package:test/bootstrap/browser.dart'; | ||
import 'package:test/test.dart'; | ||
|
||
void main() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
our tests needs a different main function example
The change will be entirely transparent to plugins. It only affects a little bit of code deep in the services library. |
28d6953
to
6eef49d
Compare
The presubmit failures look like linter issues. Can you please fix and land this patch? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM if LGT @gaaclarke
Yea LGTM. The big thing for me was keeping the warning on overflow, thanks. |
f0b8889
to
c6f0444
Compare
This pull request is not suitable for automatic merging in its current state.
|
(i'll repush when the tree failure is fixed) |
This laids the groundwork for sending messages through ChannelBuffers with channel-specific callbacks rather than a single onPlatformMessage callback. This allows us to remove the logic from the framework that puts data back into the channel buffers. Right now (before this PR) the logic for messages from plugins to the framework is bidirectional: ``` ** * Plugins -> Engine -> ChannelBuffers <- Framework <---+-. | | | | | '------> via drain ----' | | | '----------------- onPlatformMessage ---' * = when the listener is null on the framework side ** = when onPlatformMessage is null ``` This ends up with weird race conditions and is generally less than completely clear. With this PR, we lay the groundwork for eventually reaching this model: ``` Plugins -> Engine -> ChannelBuffers -> Framework ``` ...which is significantly simpler.
...but add a control message to disable it. Also while we're at it move the control messages to the standard codec.
@Hixie Tree is open now. Let's land this ASAP before more conflicts creep in. |
I see he is out this week. Will followup when he returns. |
I was going to try to land this but given the conflicts it'll have to take longer since I'll have to figure out how to merge it in. |
Sounds good. I'll add the WIP tag so I don't keep nagging you during PR triage. |
I'll close this in favour of a merged version here: #22181 |
Ok, the merge is ready in #22181. It can land once it has a review. |
This laids the groundwork for sending messages through ChannelBuffers with channel-specific callbacks rather than a single onPlatformMessage callback.
This allows us to remove the logic from the framework that puts data back into the channel buffers. Right now (before this PR) the logic for messages from plugins to the framework is bidirectional:
This ends up with weird race conditions and is generally less than completely clear. With this PR, we lay the groundwork for eventually reaching this model:
...which is significantly simpler.