TL;DR
This example shows how a plugin can translate PlayerChatEvent messages with an online API:
// Phase II: changes to the Player class for firing PlayerChatEvent
function Player::handleChat(){
$event = new PlayerChatEvent(...);
// $server->getPluginManager()->callEent($event); // the traditional way
// the new way is like this:
$server->getPluginManager()->callAsyncEvent($event, function(PlayerChatEvent $event){
if(!$event->isCancelled()){
foreach($event->getRecipients() as $recipient){
if(!$recipient->isOnline()) continue; // Resources may have been invalidated after the event call ends.
$recipient->sendMessage($event->getMessage()); // yes, I know it has some sprintf() stuff, but this is just an example
}
});
}
class TranslatePlugin extends PluginBase implements Listener{
public function onPlayerChat(PlayerChatEvent $event){
$event->pause();
$this->getServer()->getScheduler()->scheduleAsyncTask(new TranslateTask($event));
}
}
class TranslateTask extends AsyncTask{
private $message;
public function __construct(PlayerChatEvent $event){
$this->storeLocal($event);
$this->message = $event->getMessage();
}
public function onRun(){
$result = Utils::getURL("https://example.com/translate?text=" . urlencode($this->message));
$this->setResult($result);
}
public function onCompletion(Server $server){
$event = $this->fetchLocal($server);
$result = $this->getResult();
if($result === false) $event->setCancelled();
else $event->setMessage($result);
$event->continue();
}
}
There are no changes to PlayerChatEvent.php
Warning: The following RFC has more than 1500 words. If you don't want to read it, just read the TL;DR above and the "Expected use cases" at the end.
Introduction: Why async events?
It is difficult to write a plugin that fetches data asynchronously, because many parts of the PocketMine API expect you to return a value immediately.
Currently, plugins usually use either of these two solutions:
PlayerChatEvent->setMessage().Each of these solutions has serious drawbacks. This RFC seeks to provide a better solution on behalf of the events API itself.
This RFC allows event handlers to properly pause the whole event. The message in PlayerChatEvent will not be sent until you are ready; the player in PlayerPreLoginEvent will stay "Connecting to server..." until you get your data.
The mechanism comprises two parts: event calling and event handling.
Traditional way of calling events:
$PluginManager->callEvent($Event);
To call async events:
$PluginManager->callAsyncEvent($Event, function($Event){});
where the second parameter is the handler when the event completes.
It is yet to be decided whether callAsyncEvent or callEventAsync would be better.
Any event can be called asynchronously. Whether an event can be handled asynchronously depends on the caller, not the event type.
An event handler can request the event execution to be paused by calling Event->pause(), and continue it with Event->continue().
If Event->continue() is never called, the event would theoretically get stuck forever. To prevent this, PocketMine should be responsible for tracking the executing async events. By default, an event can only pause for 5 seconds; beyond that time limit, PocketMine will trigger an error message and the event will continue executing (go to next handler, or complete). There would not be effective measures to stop the event from modifying event data later (e.g. while executing another handler), but since an error message is shown, it should be clear enough that there is something wrong, and the problem of timeout rather than the problem of concurrent event modification should be tackled.
Event->pause() accepts an optional argument float $timeout = 5 specifying the timeout in seconds. The implementation shall ensure the timeout not to exceed 60. Event subclasses may also override this method to disallow asynchronous event handling or limit the timeout. However, PocketMine shall hard-limit this timeout to 60 seconds to prevent stupid abusive behaviour.
(It is yet to be decided whether this should be in seconds or in ticks -- using ticks seems more reliable)
It is also proposed that a second optional argument, ?callable $onInterrupt, be accepted, which can be triggered when the timeout is reached. If this is null, the default behaviour (display error message) will be triggered.
In cases where the event is called with the traditional callEvent rather than callAsyncEvent, Event->pause() shall throw an InvalidStateException upon called.
callAsyncEvent ---> first handler (---> nth handler)* ---> onComplete
For each of the --->, the flow of control between is _undefined_ -- callAsyncEvent may or may not immediately result in an onComplete call, or there may be a few ticks in between even if there are no handlers at all -- developers should not rely on any observed or predicted behaviour regarding this, as this is subject to change.
As it currently is, the Event object should be recyclable _after the event call has fully completed_ (after onComplete is called; not during onComplete is called). Therefore, the implementation in PocketMine is responsible for resetting an Event to its initial state (in terms of what PocketMine will change) after onComplete is called.
Current events triggered by PocketMine shall be gradually migrated from callEvent to callAsyncEvent. This may involve complex refactoring in major sections of PocketMine's source code, and can be carried out throughout a longer span.
This RFC aims to preserve as much backward compatibility as possible, as this change is not totally necessary.
Traditional event handlers for traditional event calls do are not affected.
Event handlers must no longer assume that the event is fired in the same tick the event is eventually executed. This may need to minor but unobservable bugs with some hacky plugins.
Calling events in the traditional way should not result in any semantic changes. If other (newer) plugins want to handle events from a non-async call, it's their own fault.
There is minimal backward incompatibility for event subclasses. There are only edge cases with which this proposal will clash, namely:
Event subclass with a method called pause/continueEvent subclass to be serializable/thread-safe. This is because handler queue may be stored in the Event class in the implementation.This RFC involves changes to the event calling mechanism. Its impact on performance is yet to be assessed after #1792 has been finalized, as it depends on #1792 (see below).
Tentatively, almost no impact on callEvent performance is expected. Minimal difference in performance between a callEvent and a callAsyncEvent call is expected, because the handler queue can be merged into an iterable array held in the Event instance. However, regular checking of timeouts may consume CPU unnecessarily, and shall be configurable.
This RFC targets the API version that introduces #1792, or versions after that. Implementation will be based on #1792, because there may be semantic complexities regarding how a listener is triggered.
As stated in the introduction, this is mainly used for handlers that use AsyncTask to fetch some data. Nevertheless, it is also possible to suspend an event to wait for user input. If the event caller doesn't like this, they should override the pause method to limit the event to an acceptable amount of timeout.
One possible abuse include "queuing". For example, it may be tempting for a developer to pause a PlayerPreLoginEvent to wait for the server to get less full. This is an incorrect use case, because it is totally uncertain when an online player would quit voluntarily. Instead, this suspends the server's resources for some time (although limited to be 1 minute by specification in this RFC), and weakens existent mechanisms like player limit.
On the other hand, it is correct to use this, for example, to wait for an immediate user input. For example, a developer could suspend a PlayerCommandPreprocessEvent of /kill to send a modal form, "Do you really want to suicide?" This is a form of feedback that the user is expected to reply to immediately, and setting the timeout to a few seconds is reasonable. The post-execution of PlayerCommandPreprocessEvent is pretty light as well. (As a matter of fact, the forms API as currently in #1476 is pretty similar to how AsyncTask looks like)
Plugins can always register one LOWEST (or other priorities) and one HIGHEST priority handler respectively, where they start the AsyncTask at LOWEST without pausing the event there, and then pause the event and wait for the result at the HIGHEST (or otherwise any other appropriate) priority. This allows multiple plugins to execute their async tasks simultaneously. In this way, rather than adding up the waiting time for all events, only the waiting time of the slowest one will be the bottleneck. This concept is slightly complicated for elementary developers. On behalf of PocketMine API, it's possible although pretty difficult (by difficult, I mean we will spend weeks arguing which approach is better, just like... ykwim) to add a more convenient framework for such a mechanism. If this is to be done, it is out of scope of this RFC.
Not all events should be allowed to be async. In particular, it is almost a "must not" for high-frequency events like PlayerMoveEvent to be async, because hundreds or thousands of other instances of the same event will execute during the async time.
Suggestions:
Add another optional argument to go with the timeout argument (depending on value states if it is in ticks or seconds).
Make a new async creator to go with it, to allow an integeral and a floating-point number to specify the maximum (ticks & seconds), maybe with an enum like so:
@CtrlAltCuteness Allowing both ticks and seconds is unreasonable. I suggested using ticks because it may be correlated with how tasks execute on the server (which does not change with real-life time when the server is laggy. I just suggested real-life time too because that's what native timeout functions accept. But if both time systems are supported, it is an overkill. Timeout is, after all, just a mechanism to prevent bugs.
I don't understand what you mean in the second point, but note that the error operation can be overridden in a second parameter of Event->pause().
I am inclined to agree with @SOF3 for the usage of ticks. You can always provide a lower bound in seconds given an amount of ticks (multiply by 20). However, when converting from seconds to ticks, you have an upper bound. I think in timeouts, a being given a little extra is better than a little less.
It is yet to be decided whether
callAsyncEventorcallEventAsyncwould be better.
I much prefer callAsyncEvent and ticks. Nice proposal :-)
Sorry @SOF3 as I think I was not fully awake at the time. The idea is to allow a unexpectedly slow local network (or relatively slow HTTP request & parse, etc.) to get data from a server database independent of the game speed, while knowing if it is too slow for a (example) game time-critical data fetch.
Actually, the pause timeout is only to prevent people from forgetting to continue() the event. It is a real error, and it was not intended to help you handle expected timeout conditions.
In other words, the pause timeout should never occur in a production scenario. Developers are responsible for preventing this timeout before PocketMine notices it.
I doubt if I should even allow suppressing the error message.
Understood. Thank you for clarifying, @SOF3
Proposed addition.
Behavioural changes from the suggested result in the proposal:
@completePriority (which should be higher than or equal to @priority)int $priority parameter (instead of @completePriority in the doccomment), where the new priority should be ≥ the handler's original @priority. Default equal to @priority.Obsoleted by #2470.
Most helpful comment
I much prefer
callAsyncEventand ticks. Nice proposal :-)