diff --git a/src/event/AsyncEvent.php b/src/event/AsyncEvent.php new file mode 100644 index 00000000000..12337602473 --- /dev/null +++ b/src/event/AsyncEvent.php @@ -0,0 +1,156 @@ +> $promises */ + private ObjectSet $promises; + /** @var array, int> $delegatesCallDepth */ + private static array $delegatesCallDepth = []; + private const MAX_EVENT_CALL_DEPTH = 50; + + /** + * @phpstan-return Promise + */ + final public function call() : Promise{ + $this->promises = new ObjectSet(); + if(!isset(self::$delegatesCallDepth[$class = static::class])){ + self::$delegatesCallDepth[$class] = 0; + } + + if(self::$delegatesCallDepth[$class] >= 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)"); + } + + $timings = Timings::getAsyncEventTimings($this); + $timings->startTiming(); + + ++self::$delegatesCallDepth[$class]; + try{ + return $this->callAsyncDepth(); + }finally{ + --self::$delegatesCallDepth[$class]; + $timings->stopTiming(); + } + } + + /** + * @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($this); + }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(static::class)->getListenersByPriority($priority); + + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + + $nonConcurrentHandlers = []; + foreach($handlers as $registration){ + assert($registration instanceof RegisteredAsyncListener); + if($registration->canBeCalledConcurrently()){ + $result = $registration->callAsync($this); + if($result !== null) { + $this->promises->add($result); + } + }else{ + $nonConcurrentHandlers[] = $registration; + } + } + + $testResolve = function() use (&$nonConcurrentHandlers, &$testResolve, $resolver){ + if(count($nonConcurrentHandlers) === 0){ + $this->waitForPromises()->onCompletion(function() use ($resolver){ + $resolver->resolve(null); + }, function() use ($resolver){ + $resolver->reject(); + }); + }else{ + $this->waitForPromises()->onCompletion(function() use (&$nonConcurrentHandlers, $testResolve){ + $handler = array_shift($nonConcurrentHandlers); + assert($handler instanceof RegisteredAsyncListener); + $result = $handler->callAsync($this); + if($result !== null) { + $this->promises->add($result); + } + $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/HandlerList.php b/src/event/HandlerList.php index 2072cd5226f..89629e140e3 100644 --- a/src/event/HandlerList.php +++ b/src/event/HandlerList.php @@ -25,6 +25,7 @@ use pocketmine\plugin\Plugin; use function array_merge; +use function array_merge_recursive; use function krsort; use function spl_object_id; use const SORT_NUMERIC; @@ -37,7 +38,7 @@ class HandlerList{ private array $affectedHandlerCaches = []; /** - * @phpstan-param class-string $class + * @phpstan-param class-string $class */ public function __construct( private string $class, @@ -126,12 +127,23 @@ public function getListenerList() : array{ $handlerLists[] = $currentList; } - $listenersByPriority = []; + $listeners = []; + $asyncListeners = []; + $exclusiveAsyncListeners = []; foreach($handlerLists as $currentList){ - foreach($currentList->handlerSlots as $priority => $listeners){ - $listenersByPriority[$priority] = array_merge($listenersByPriority[$priority] ?? [], $listeners); + foreach($currentList->handlerSlots as $priority => $listenersToSort){ + foreach($listenersToSort as $listener){ + if(!$listener instanceof RegisteredAsyncListener){ + $listeners[$priority][] = $listener; + }elseif(!$listener->canBeCalledConcurrently()){ + $asyncListeners[$priority][] = $listener; + }else{ + $exclusiveAsyncListeners[$priority][] = $listener; + } + } } } + $listenersByPriority = array_merge_recursive($listeners, $asyncListeners, $exclusiveAsyncListeners); //TODO: why on earth do the priorities have higher values for lower priority? krsort($listenersByPriority, SORT_NUMERIC); diff --git a/src/event/HandlerListManager.php b/src/event/HandlerListManager.php index 605a3874789..4fc9ac3052d 100644 --- a/src/event/HandlerListManager.php +++ b/src/event/HandlerListManager.php @@ -38,7 +38,7 @@ public static function global() : self{ private array $allLists = []; /** * @var RegisteredListenerCache[] event class name => cache - * @phpstan-var array, RegisteredListenerCache> + * @phpstan-var array, RegisteredListenerCache> */ private array $handlerCaches = []; @@ -59,7 +59,7 @@ public function unregisterAll(RegisteredListener|Plugin|Listener|null $object = } /** - * @phpstan-param \ReflectionClass $class + * @phpstan-param \ReflectionClass $class */ private static function isValidClass(\ReflectionClass $class) : bool{ $tags = Utils::parseDocComment((string) $class->getDocComment()); @@ -67,9 +67,9 @@ private static function isValidClass(\ReflectionClass $class) : bool{ } /** - * @phpstan-param \ReflectionClass $class + * @phpstan-param \ReflectionClass $class * - * @phpstan-return \ReflectionClass|null + * @phpstan-return \ReflectionClass|null */ private static function resolveNearestHandleableParent(\ReflectionClass $class) : ?\ReflectionClass{ for($parent = $class->getParentClass(); $parent !== false; $parent = $parent->getParentClass()){ @@ -86,7 +86,8 @@ private static function resolveNearestHandleableParent(\ReflectionClass $class) * * Calling this method also lazily initializes the $classMap inheritance tree of handler lists. * - * @phpstan-param class-string $event + * @phpstan-template TEvent of Event|AsyncEvent + * @phpstan-param class-string $event * * @throws \ReflectionException * @throws \InvalidArgumentException @@ -112,7 +113,7 @@ public function getListFor(string $event) : HandlerList{ } /** - * @phpstan-param class-string $event + * @phpstan-param class-string $event * * @return RegisteredListener[] */ diff --git a/src/event/ListenerMethodTags.php b/src/event/ListenerMethodTags.php index cb932ce27ac..e65f25f80ba 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 EXCLUSIVE_CALL = "exclusiveCall"; } diff --git a/src/event/RegisteredAsyncListener.php b/src/event/RegisteredAsyncListener.php new file mode 100644 index 00000000000..6a0413d9930 --- /dev/null +++ b/src/event/RegisteredAsyncListener.php @@ -0,0 +1,67 @@ + $handler + */ + public function __construct( + protected \Closure $handler, + int $priority, + Plugin $plugin, + bool $handleCancelled, + private bool $exclusiveCall, + protected TimingsHandler $timings + ){ + parent::__construct($handler, $priority, $plugin, $handleCancelled, $timings); + } + + public function canBeCalledConcurrently() : bool{ + return !$this->exclusiveCall; + } + + public function callEvent(Event $event) : void{ + throw new \BadMethodCallException("Cannot call async event synchronously, use callAsync() instead"); + } + + /** + * @phpstan-return Promise|null + */ + public function callAsync(AsyncEvent $event) : ?Promise{ + if($event instanceof Cancellable && $event->isCancelled() && !$this->isHandlingCancelled()){ + return null; + } + $this->timings->startTiming(); + try{ + return ($this->handler)($event); + }finally{ + $this->timings->stopTiming(); + } + } +} diff --git a/src/event/player/PlayerChatAsyncEvent.php b/src/event/player/PlayerChatAsyncEvent.php new file mode 100644 index 00000000000..c520aa5a117 --- /dev/null +++ b/src/event/player/PlayerChatAsyncEvent.php @@ -0,0 +1,92 @@ +message; + } + + public function setMessage(string $message) : void{ + $this->message = $message; + } + + /** + * Changes the player that is sending the message + */ + public function setPlayer(Player $player) : void{ + $this->player = $player; + } + + public function getPlayer() : Player{ + return $this->player; + } + + public function getFormatter() : ChatFormatter{ + return $this->formatter; + } + + public function setFormatter(ChatFormatter $formatter) : void{ + $this->formatter = $formatter; + } + + /** + * @return CommandSender[] + */ + public function getRecipients() : array{ + return $this->recipients; + } + + /** + * @param CommandSender[] $recipients + */ + public function setRecipients(array $recipients) : void{ + Utils::validateArrayValueType($recipients, function(CommandSender $_) : void{}); + $this->recipients = $recipients; + } +} diff --git a/src/plugin/PluginManager.php b/src/plugin/PluginManager.php index 198e4e893bf..ca126ab4cfd 100644 --- a/src/plugin/PluginManager.php +++ b/src/plugin/PluginManager.php @@ -23,6 +23,7 @@ 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\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; @@ -575,7 +578,7 @@ private function getEventsHandledBy(\ReflectionMethod $method) : ?string{ /** @phpstan-var class-string $paramClass */ $paramClass = $paramType->getName(); $eventClass = new \ReflectionClass($paramClass); - if(!$eventClass->isSubclassOf(Event::class)){ + if(!$eventClass->isSubclassOf(Event::class) && !$eventClass->isSubclassOf(AsyncEvent::class)){ return null; } @@ -629,8 +632,33 @@ public function registerEvents(Listener $listener, Plugin $plugin) : void{ throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::HANDLE_CANCELLED . " value \"" . $tags[ListenerMethodTags::HANDLE_CANCELLED] . "\""); } } + $exclusiveCall = false; + if(isset($tags[ListenerMethodTags::EXCLUSIVE_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::EXCLUSIVE_CALL, + $eventClass + )); + } + switch(strtolower($tags[ListenerMethodTags::EXCLUSIVE_CALL])){ + case "true": + case "": + $exclusiveCall = true; + break; + case "false": + break; + default: + throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::EXCLUSIVE_CALL . " value \"" . $tags[ListenerMethodTags::EXCLUSIVE_CALL] . "\""); + } + } - $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled); + if(is_subclass_of($eventClass, AsyncEvent::class) && $this->canHandleAsyncEvent($handlerClosure)){ + $this->registerAsyncEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled, $exclusiveCall); + }else{ + $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled); + } } } @@ -665,4 +693,44 @@ public function registerEvent(string $event, \Closure $handler, int $priority, P HandlerListManager::global()->getListFor($event)->register($registeredListener); return $registeredListener; } + + /** + * @param string $event Class name that extends Event and AsyncEvent + * + * @phpstan-param class-string $event + * @phpstan-param \Closure(AsyncEvent) : Promise $handler + * + * @throws \ReflectionException + */ + public function registerAsyncEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled = false, bool $exclusiveCall = false) : RegisteredAsyncListener{ + if(!is_subclass_of($event, AsyncEvent::class)){ + throw new PluginException($event . " is not an AsyncEvent"); + } + + $handlerName = Utils::getNiceClosureName($handler); + + if(!$plugin->isEnabled()){ + throw new PluginException("Plugin attempted to register event handler " . $handlerName . "() to event " . $event . " while not enabled"); + } + + $timings = Timings::getEventHandlerTimings($event, $handlerName, $plugin->getDescription()->getFullName()); + + $registeredListener = new RegisteredAsyncListener($handler, $priority, $plugin, $handleCancelled, $exclusiveCall, $timings); + HandlerListManager::global()->getListFor($event)->register($registeredListener); + return $registeredListener; + } + + /** + * Check if the given handler return type is async-compatible (equal to Promise) + * + * @phpstan-param \Closure(AsyncEvent) : Promise $handler + * + * @throws \ReflectionException + */ + private function canHandleAsyncEvent(\Closure $handler) : bool{ + $reflection = new \ReflectionFunction($handler); + $return = $reflection->getReturnType(); + + return $return instanceof \ReflectionNamedType && $return->getName() === Promise::class; + } } diff --git a/src/timings/Timings.php b/src/timings/Timings.php index 563af69bff6..19e8db145a8 100644 --- a/src/timings/Timings.php +++ b/src/timings/Timings.php @@ -25,6 +25,7 @@ use pocketmine\block\tile\Tile; use pocketmine\entity\Entity; +use pocketmine\event\AsyncEvent; use pocketmine\event\Event; use pocketmine\network\mcpe\protocol\ClientboundPacket; use pocketmine\network\mcpe\protocol\ServerboundPacket; @@ -114,6 +115,8 @@ abstract class Timings{ /** @var TimingsHandler[] */ private static array $events = []; + /** @var TimingsHandler[] */ + private static array $asyncEvents = []; /** @var TimingsHandler[][] */ private static array $eventHandlers = []; @@ -300,8 +303,18 @@ public static function getEventTimings(Event $event) : TimingsHandler{ return self::$events[$eventClass]; } + public static function getAsyncEventTimings(AsyncEvent $event) : TimingsHandler{ + $eventClass = get_class($event); + if(!isset(self::$asyncEvents[$eventClass])){ + self::$asyncEvents[$eventClass] = new TimingsHandler(self::shortenCoreClassName($eventClass, "pocketmine\\event\\"), group: "Events"); + } + + return self::$asyncEvents[$eventClass]; + } + /** - * @phpstan-param class-string $event + * @phpstan-template TEvent of Event|AsyncEvent + * @phpstan-param class-string $event */ public static function getEventHandlerTimings(string $event, string $handlerName, string $group) : TimingsHandler{ if(!isset(self::$eventHandlers[$event][$handlerName])){ diff --git a/tests/phpstan/configs/actual-problems.neon b/tests/phpstan/configs/actual-problems.neon index cc647da8050..d7fe2d87447 100644 --- a/tests/phpstan/configs/actual-problems.neon +++ b/tests/phpstan/configs/actual-problems.neon @@ -1205,3 +1205,23 @@ parameters: count: 1 path: ../../phpunit/scheduler/AsyncPoolTest.php + - + message: "#^Right side of && is always true\\.$#" + count: 1 + path: ../../../src/promise/Promise.php + + - + message: "#^Parameter \\#1 \\$value of method pocketmine\\\\promise\\\\PromiseResolver\\\\:\\:resolve\\(\\) expects null, string given\\.$#" + count: 2 + path: ../../../src/event/AsyncEventDelegate.php + + - + message: "#^Parameter \\#1 \\$handler of class pocketmine\\\\event\\\\RegisteredAsyncListener constructor expects Closure\\(pocketmine\\\\event\\\\AsyncEvent&pocketmine\\\\event\\\\Event\\)\\: pocketmine\\\\promise\\\\Promise\\, \\(Closure\\(TEvent\\)\\: void\\)\\|\\(Closure\\(pocketmine\\\\event\\\\AsyncEvent&TEvent\\)\\: pocketmine\\\\promise\\\\Promise\\\\) given\\.$#" + count: 1 + path: ../../../src/plugin/PluginManager.php + + - + message: "#^Parameter \\#1 \\$handler of class pocketmine\\\\event\\\\RegisteredAsyncListener constructor expects Closure\\(pocketmine\\\\event\\\\AsyncEvent&pocketmine\\\\event\\\\Event\\)\\: pocketmine\\\\promise\\\\Promise\\, Closure\\(TEvent\\)\\: pocketmine\\\\promise\\\\Promise\\ given\\.$#" + count: 1 + path: ../../../src/plugin/PluginManager.php +