diff --git a/src/event/AsyncEvent.php b/src/event/AsyncEvent.php index 3902a96ff..9f1784b90 100644 --- a/src/event/AsyncEvent.php +++ b/src/event/AsyncEvent.php @@ -24,7 +24,6 @@ declare(strict_types=1); namespace pocketmine\event; use pocketmine\promise\Promise; -use pocketmine\utils\ObjectSet; /** * This interface is implemented by an Event subclass if and only if it can be called asynchronously. @@ -34,20 +33,11 @@ use pocketmine\utils\ObjectSet; * When all the promises of a priority level have been resolved, the next priority level is called. */ interface AsyncEvent{ - /** - * Add a promise to the set of promises that will be awaited before the next priority level is called. - * - * @phpstan-param Promise $promise - */ - public function addPromise(Promise $promise) : void; - /** * Be prudent, calling an event asynchronously can produce unexpected results. * During the execution of the event, the server, the player and the event context may have changed state. * - * @phpstan-param ObjectSet> $promiseSet - * * @phpstan-return Promise */ - public static function callAsync(AsyncEvent&Event $event, ObjectSet $promiseSet) : Promise; + public function callAsync() : Promise; } diff --git a/src/event/AsyncEventDelegate.php b/src/event/AsyncEventDelegate.php new file mode 100644 index 000000000..1811e349a --- /dev/null +++ b/src/event/AsyncEventDelegate.php @@ -0,0 +1,131 @@ +> $promises */ + private ObjectSet $promises; + + public function __construct( + private AsyncEvent&Event $event + ){ + $this->promises = new ObjectSet(); + } + + /** + * @phpstan-return Promise + */ + public function callAsync() : Promise { + $this->promises->clear(); + return $this->callDepth($this->callAsyncDepth(...)); + } + + /** + * @phpstan-return Promise + */ + private function callAsyncDepth() : Promise{ + /** @phpstan-var PromiseResolver $globalResolver */ + $globalResolver = new PromiseResolver(); + + $priorities = EventPriority::ALL; + $testResolve = function () use (&$testResolve, &$priorities, $globalResolver){ + if(count($priorities) === 0){ + $globalResolver->resolve(""); // TODO: see #6110 + }else{ + $this->callPriority(array_shift($priorities))->onCompletion(function() use ($testResolve) : void{ + $testResolve(); + }, function () use ($globalResolver) { + $globalResolver->reject(); + }); + } + }; + + $testResolve(); + + return $globalResolver->getPromise(); + } + + /** + * @phpstan-return Promise + */ + private function callPriority(int $priority) : Promise { + $handlers = HandlerListManager::global()->getListFor($this->event::class)->getListenersByPriority($priority); + + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + + $nonConcurrentHandlers = []; + foreach($handlers as $registration){ + if($registration instanceof RegisteredAsyncListener){ + if($registration->canBeCallConcurrently()){ + $this->promises->add($registration->callAsync($this->event)); + }else{ + $nonConcurrentHandlers[] = $registration; + } + }else{ + $registration->callEvent($this->event); + } + } + + $testResolve = function() use (&$nonConcurrentHandlers, &$testResolve, $resolver){ + if(count($nonConcurrentHandlers) === 0){ + $this->waitForPromises()->onCompletion(function() use ($resolver){ + $resolver->resolve(""); // TODO: see #6110 + }, function() use ($resolver){ + $resolver->reject(); + }); + }else{ + $this->waitForPromises()->onCompletion(function() use (&$nonConcurrentHandlers, $testResolve) { + $handler = array_shift($nonConcurrentHandlers); + if($handler instanceof RegisteredAsyncListener){ + $this->promises->add($handler->callAsync($this->event)); + } + $testResolve(); + }, function() use ($resolver) { + $resolver->reject(); + }); + } + }; + + $testResolve(); + + return $resolver->getPromise(); + } + + /** + * @phpstan-return Promise + */ + private function waitForPromises() : Promise { + $array = $this->promises->toArray(); + $this->promises->clear(); + + return Promise::all($array); + } +} diff --git a/src/event/AsyncEventTrait.php b/src/event/AsyncEventTrait.php index b907ba5a3..5fd12aca0 100644 --- a/src/event/AsyncEventTrait.php +++ b/src/event/AsyncEventTrait.php @@ -24,61 +24,17 @@ declare(strict_types=1); namespace pocketmine\event; use pocketmine\promise\Promise; -use pocketmine\promise\PromiseResolver; -use pocketmine\utils\ObjectSet; -use function array_shift; -use function count; trait AsyncEventTrait { - /** @phpstan-var ObjectSet> */ - private ObjectSet $promises; + private AsyncEventDelegate $delegate; /** - * @phpstan-param ObjectSet>|null $promises + * @phpstan-return Promise */ - private function initializePromises(?ObjectSet &$promises) : void{ - $promises ??= new ObjectSet(); - $this->promises = $promises; - } - - public function addPromise(Promise $promise) : void{ - if(!isset($this->promises)){ - throw new \RuntimeException("Cannot add promises, be sure to initialize the promises set in the constructor"); + final public function callAsync() : Promise{ + if(!isset($this->delegate)){ + $this->delegate = new AsyncEventDelegate($this); } - $this->promises->add($promise); - } - - final public static function callAsync(AsyncEvent&Event $event, ObjectSet $promiseSet) : Promise{ - $event->checkMaxDepthCall(); - - /** @phpstan-var PromiseResolver $globalResolver */ - $globalResolver = new PromiseResolver(); - - $callable = function(int $priority) use ($event, $promiseSet) : Promise{ - $handlers = HandlerListManager::global()->getListFor(static::class)->getListenersByPriority($priority); - $event->callHandlers($handlers); - - $array = $promiseSet->toArray(); - $promiseSet->clear(); - - return Promise::all($array); - }; - - $priorities = EventPriority::ALL; - $testResolve = function () use (&$testResolve, &$priorities, $callable, $globalResolver){ - if(count($priorities) === 0){ - $globalResolver->resolve(null); - }else{ - $callable(array_shift($priorities))->onCompletion(function() use ($testResolve) : void{ - $testResolve(); - }, function () use ($globalResolver) { - $globalResolver->reject(); - }); - } - }; - - $testResolve(); - - return $globalResolver->getPromise(); + return $this->delegate->callAsync(); } } diff --git a/src/event/Event.php b/src/event/Event.php index 6334c16b2..6ba39031a 100644 --- a/src/event/Event.php +++ b/src/event/Event.php @@ -47,36 +47,27 @@ abstract class Event{ * @throws \RuntimeException if event call recursion reaches the max depth limit */ public function call() : void{ - $this->checkMaxDepthCall(); - $this->callHandlers(null); + $this->callDepth(function() { + $handlers = HandlerListManager::global()->getHandlersFor(static::class); + + foreach($handlers as $registration){ + $registration->callEvent($this); + } + }); } - /** - * @internal used by AsyncEventTrait and Event - */ - final protected function checkMaxDepthCall() : void{ + final protected function callDepth(\Closure $closure) : mixed{ if(self::$eventCallDepth >= self::MAX_EVENT_CALL_DEPTH){ //this exception will be caught by the parent event call if all else fails throw new \RuntimeException("Recursive event call detected (reached max depth of " . self::MAX_EVENT_CALL_DEPTH . " calls)"); } - } - /** - * @param RegisteredListener[]|null $handlers - * - * @internal used by AsyncEventTrait and Event - */ - final protected function callHandlers(?array $handlers) : void{ $timings = Timings::getEventTimings($this); $timings->startTiming(); - $handlers = $handlers ?? HandlerListManager::global()->getHandlersFor(static::class); - ++self::$eventCallDepth; try{ - foreach($handlers as $registration){ - $registration->callEvent($this); - } + return $closure(); }finally{ --self::$eventCallDepth; $timings->stopTiming(); diff --git a/src/event/HandlerList.php b/src/event/HandlerList.php index 74eedf3a4..b99a12854 100644 --- a/src/event/HandlerList.php +++ b/src/event/HandlerList.php @@ -24,9 +24,11 @@ declare(strict_types=1); namespace pocketmine\event; use pocketmine\plugin\Plugin; +use function array_filter; use function array_merge; use function krsort; use function spl_object_id; +use function usort; use const SORT_NUMERIC; class HandlerList{ @@ -128,11 +130,26 @@ class HandlerList{ } $listenersByPriority = []; + $asyncListenersByPriority = []; foreach($handlerLists as $currentList){ foreach($currentList->handlerSlots as $priority => $listeners){ - $listenersByPriority[$priority] = array_merge($listenersByPriority[$priority] ?? [], $listeners); + $syncListeners = array_filter($listeners, static function(RegisteredListener $listener) : bool{ return !($listener instanceof RegisteredAsyncListener); }); + $listenersByPriority[$priority] = array_merge($listenersByPriority[$priority] ?? [], $syncListeners); + + $asyncListeners = array_filter($listeners, static function(RegisteredListener $listener) : bool{ return $listener instanceof RegisteredAsyncListener; }); + $asyncListenersByPriority[$priority] = array_merge($asyncListenersByPriority[$priority] ?? [], $asyncListeners); } } + foreach($asyncListenersByPriority as $priority => $asyncListeners){ + usort($asyncListeners, static function(RegisteredAsyncListener $a, RegisteredAsyncListener $b) : int{ + if($a->canBeCallConcurrently()){ + return $b->canBeCallConcurrently() ? 0 : -1; + }else{ + return $b->canBeCallConcurrently() ? -1 : 0; + } + }); + $listenersByPriority[$priority] = array_merge($listenersByPriority[$priority] ?? [], $asyncListeners); + } //TODO: why on earth do the priorities have higher values for lower priority? krsort($listenersByPriority, SORT_NUMERIC); diff --git a/src/event/ListenerMethodTags.php b/src/event/ListenerMethodTags.php index cb932ce27..0941fb94a 100644 --- a/src/event/ListenerMethodTags.php +++ b/src/event/ListenerMethodTags.php @@ -31,4 +31,5 @@ final class ListenerMethodTags{ public const HANDLE_CANCELLED = "handleCancelled"; public const NOT_HANDLER = "notHandler"; public const PRIORITY = "priority"; + public const NO_CONCURRENT_CALL = "noConcurrentCall"; } diff --git a/src/event/RegisteredAsyncListener.php b/src/event/RegisteredAsyncListener.php new file mode 100644 index 000000000..e5bbde84b --- /dev/null +++ b/src/event/RegisteredAsyncListener.php @@ -0,0 +1,58 @@ +returnPromise = $handler($event); + if(!$this->returnPromise instanceof Promise){ + throw new \TypeError("Async event handler must return a Promise"); + } + }; + parent::__construct($handler, $priority, $plugin, $handleCancelled, $timings); + } + + public function canBeCallConcurrently() : bool{ + return !$this->noConcurrentCall; + } + + public function callAsync(AsyncEvent&Event $event) : Promise{ + $this->callEvent($event); + return $this->returnPromise; + } +} diff --git a/src/event/player/PlayerChatEvent.php b/src/event/player/PlayerChatEvent.php index 1cdbb49fb..87151bc23 100644 --- a/src/event/player/PlayerChatEvent.php +++ b/src/event/player/PlayerChatEvent.php @@ -24,6 +24,8 @@ declare(strict_types=1); namespace pocketmine\event\player; use pocketmine\command\CommandSender; +use pocketmine\event\AsyncEvent; +use pocketmine\event\AsyncEventTrait; use pocketmine\event\Cancellable; use pocketmine\event\CancellableTrait; use pocketmine\player\chat\ChatFormatter; @@ -33,8 +35,9 @@ use pocketmine\utils\Utils; /** * Called when a player chats something */ -class PlayerChatEvent extends PlayerEvent implements Cancellable{ +class PlayerChatEvent extends PlayerEvent implements Cancellable, AsyncEvent{ use CancellableTrait; + use AsyncEventTrait; /** * @param CommandSender[] $recipients diff --git a/src/player/Player.php b/src/player/Player.php index 0afa0bffe..9969ec8be 100644 --- a/src/player/Player.php +++ b/src/player/Player.php @@ -1513,10 +1513,14 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ Timings::$playerCommand->stopTiming(); }else{ $ev = new PlayerChatEvent($this, $messagePart, $this->server->getBroadcastChannelSubscribers(Server::BROADCAST_CHANNEL_USERS), new StandardChatFormatter()); - $ev->call(); - if(!$ev->isCancelled()){ - $this->server->broadcastMessage($ev->getFormatter()->format($ev->getPlayer()->getDisplayName(), $ev->getMessage()), $ev->getRecipients()); - } + $ev->callAsync() + ->onCompletion(function() use ($ev) { + if(!$ev->isCancelled()){ + $this->server->broadcastMessage($ev->getFormatter()->format($ev->getPlayer()->getDisplayName(), $ev->getMessage()), $ev->getRecipients()); + } + }, function (){ + }); + } } } diff --git a/src/plugin/PluginManager.php b/src/plugin/PluginManager.php index 67ca8cc37..f918d916a 100644 --- a/src/plugin/PluginManager.php +++ b/src/plugin/PluginManager.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\plugin; +use pocketmine\event\AsyncEvent; use pocketmine\event\Cancellable; use pocketmine\event\Event; use pocketmine\event\EventPriority; @@ -31,11 +32,13 @@ use pocketmine\event\Listener; use pocketmine\event\ListenerMethodTags; use pocketmine\event\plugin\PluginDisableEvent; use pocketmine\event\plugin\PluginEnableEvent; +use pocketmine\event\RegisteredAsyncListener; use pocketmine\event\RegisteredListener; use pocketmine\lang\KnownTranslationFactory; use pocketmine\permission\DefaultPermissions; use pocketmine\permission\PermissionManager; use pocketmine\permission\PermissionParser; +use pocketmine\promise\Promise; use pocketmine\Server; use pocketmine\timings\Timings; use pocketmine\utils\AssumptionFailedError; @@ -626,8 +629,29 @@ class PluginManager{ throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::HANDLE_CANCELLED . " value \"" . $tags[ListenerMethodTags::HANDLE_CANCELLED] . "\""); } } + $noConcurrentCall = false; + if(isset($tags[ListenerMethodTags::NO_CONCURRENT_CALL])){ + if(!is_a($eventClass, AsyncEvent::class, true)){ + throw new PluginException(sprintf( + "Event handler %s() declares @%s for non-async event of type %s", + Utils::getNiceClosureName($handlerClosure), + ListenerMethodTags::NO_CONCURRENT_CALL, + $eventClass + )); + } + switch(strtolower($tags[ListenerMethodTags::NO_CONCURRENT_CALL])){ + case "true": + case "": + $noConcurrentCall = true; + break; + case "false": + break; + default: + throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::NO_CONCURRENT_CALL . " value \"" . $tags[ListenerMethodTags::NO_CONCURRENT_CALL] . "\""); + } + } - $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled); + $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled, $noConcurrentCall); } } @@ -636,11 +660,11 @@ class PluginManager{ * * @phpstan-template TEvent of Event * @phpstan-param class-string $event - * @phpstan-param \Closure(TEvent) : void $handler + * @phpstan-param (\Closure(TEvent) : void)|(\Closure(AsyncEvent&TEvent) : Promise) $handler * * @throws \ReflectionException */ - public function registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled = false) : RegisteredListener{ + public function registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled = false, bool $noConcurrentCall = false) : RegisteredListener{ if(!is_subclass_of($event, Event::class)){ throw new PluginException($event . " is not an Event"); } @@ -653,8 +677,25 @@ class PluginManager{ $timings = Timings::getEventHandlerTimings($event, $handlerName, $plugin->getDescription()->getFullName()); - $registeredListener = new RegisteredListener($handler, $priority, $plugin, $handleCancelled, $timings); + if(is_subclass_of($event, AsyncEvent::class) && $this->canHandleAsyncEvent($handler)){ + $registeredListener = new RegisteredAsyncListener($handler, $priority, $plugin, $handleCancelled, $noConcurrentCall, $timings); + }else{ + $registeredListener = new RegisteredListener($handler, $priority, $plugin, $handleCancelled, $timings); + } HandlerListManager::global()->getListFor($event)->register($registeredListener); return $registeredListener; } + + /** + * Check if the given handler return type is async-compatible (equal to Promise) + * + * @phpstan-template TEvent of Event + * @phpstan-param (\Closure(TEvent) : void)|(\Closure(AsyncEvent&TEvent) : Promise) $handler + */ + private function canHandleAsyncEvent(\Closure $handler) : bool { + $reflection = new \ReflectionFunction($handler); + $return = $reflection->getReturnType(); + + return $return instanceof \ReflectionNamedType && $return->getName() === Promise::class; + } }