From 1fb68bf358f6521432152dedec9549a3bfabf2cf Mon Sep 17 00:00:00 2001 From: DaisukeDaisuke <17798680+DaisukeDaisuke@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:19:20 +0900 Subject: [PATCH 1/5] v5.0.0 --- README.md | 78 ++++++++++++------- .../AwaitFormOptions/AwaitFormOptions.php | 50 ++++++++---- .../AwaitFormOptions/FormBridgeTrait.php | 6 +- .../AwaitFormOptions/FormOptions.php | 2 +- .../AwaitFormOptions/MenuOptions.php | 2 +- .../RequestResponseBridge.php | 33 ++++---- .../exception/AwaitFormOptionsException.php | 9 +++ .../exception/AwaitFormOptionsExcption.php | 7 +- .../AwaitFormOptionsInvalidValueException.php | 2 +- tests/RequestResponseBridgeTest.php | 36 +++++++++ 10 files changed, 156 insertions(+), 69 deletions(-) create mode 100644 src/DaisukeDaisuke/AwaitFormOptions/exception/AwaitFormOptionsException.php diff --git a/README.md b/README.md index 46c3842..a1c74c8 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Each option will yield from `$this->request($form);` and wait for the response. > > Additionally, the following exceptions may be thrown from `request()`: > - `AwaitFormOptionsExpectedCrashException`: When `request()` is called more than once in the same generator. -> - `AwaitFormOptionsExpectedCrashException`: When the provided form/button array is invalid. +> - `AwaitFormOptionsExpectedCrashException`: When the provided payload is invalid, such as a `MenuElement` in a form request, a `FormControl` in a menu request, or an invalid tuple shape. > - `AwaitFormOptionsChildException`: If the player rejects the form, input is invalid, or the player logs out. > > The try-catch in the child generator may be omitted, but is not recommended. @@ -196,8 +196,8 @@ class HPFormOptions extends FormOptions{ ## Reusability -Yes, option classes are reusable! -Try passing the same class multiple times: +Yes, option classes are reusable by creating separate instances. +Try passing multiple instances of the same class: ```php public function a(PlayerItemUseEvent $event): void { @@ -252,6 +252,24 @@ public function a(PlayerItemUseEvent $event): void { --- +## Request Payload Shapes + +Inside a child generator, `request()` registers the form controls or menu elements contributed by that generator. + +For forms: +- Use `FormControl` values, or `[FormControl, key]` tuples. +- A bare `FormControl` uses the request array key as the response key. +- An explicit form key must be scalar. Object, array, and `null` keys are rejected because form responses are returned as PHP array keys. + +For menus: +- Use `MenuElement` values, or `[MenuElement, value]` tuples. +- A bare `MenuElement` uses the request array key as the value returned from `request()`. +- An explicit menu value may be any value, including an object. + +`FormOptions` cannot request `MenuElement` values, and `MenuOptions` cannot request `FormControl` values. Invalid payloads are developer errors and throw `AwaitFormOptionsExpectedCrashException`. + +--- + ## Menu Support AwaitFormOptions also supports `menu` interactions. @@ -354,9 +372,9 @@ class NameMenuOptions extends MenuOptions{ --- -## Reusing Menu Options +## Using Multiple Menu Option Instances -Just like form options, menu options can be reused as well: +Just like form options, menu option classes can be used repeatedly by creating separate instances: ```php public function a(PlayerItemUseEvent $event): void { @@ -386,7 +404,7 @@ public function a(PlayerItemUseEvent $event): void { --- ### 🧩 Menu Advanced Usage: Attaching Objects to Buttons -Normally, MenuElement::button("label") returns a `MenuElement` that maps to a string value. +Normally, a bare `MenuElement::button("label")` maps to the key of that entry in the request array. In a normal list, that key is a numeric index. But what if you want to associate a more complex object, like a Player, Entity, or CustomData // with each button? You can do this easily by passing `[MenuElement::button(...), $value]` into the menu array. @@ -571,15 +589,16 @@ class SimpleButton extends MenuOptions{ ``` ### result -Any of the following +Any of the following: ``` int(0) int(1) int(2) int(3) -NULL ``` +If the menu fails or is closed, `sendMenuAsync()` throws `AwaitFormOptionsParentException` instead of returning a value. + --- @@ -661,6 +680,7 @@ namespace test\test; use DaisukeDaisuke\AwaitFormOptions\FormOptions; use cosmicpe\awaitform\FormControl; +use DaisukeDaisuke\AwaitFormOptions\exception\AwaitFormOptionsChildException; class SimpleInput extends FormOptions{ public function __construct(private string $text, private string $default, private string $placeholder, private int $id){ @@ -805,7 +825,7 @@ class MobKillerForm extends MenuOptions{ ## Non-Cancellable Form (Forced Confirmation) Sometimes, you want to prevent players from skipping or cancelling a form unless they acknowledge a specific phrase or condition // such as typing "yes". -With AwaitFormOptions, this can be done cleanly by combining input validation and throwExceptionInCaller: true. +With AwaitFormOptions, this can be done cleanly by retrying from the parent coroutine when the player rejects the form, and by validating the returned input before leaving the loop. ### Usage @@ -999,25 +1019,25 @@ class HpBasedFoodOptions extends MenuOptions{ ```php FormControl::divider() // Adds a horizontal divider to visually separate form sections. -FormControl::dropdown(string $label, array $options, ?string $default = null) // Select from a list of options, returns the selected value. -FormControl::dropdownIndex(string $label, array $options, int $default = 0) // Select from a list of options, returns the selected index. -FormControl::dropdownMap(string $label, array $options, array $mapping, mixed $default = null) // Select from a list of options, returns a mapped value. +FormControl::dropdown(string $label, array $options, ?string $default = null, ?string $tooltip = null) // Select from a list of options, returns the selected value. +FormControl::dropdownIndex(string $label, array $options, int $default = 0, ?string $tooltip = null) // Select from a list of options, returns the selected index. +FormControl::dropdownMap(string $label, array $options, array $mapping, mixed $default = null, ?string $tooltip = null) // Select from a list of options, returns a mapped value. FormControl::header(string $label) // Adds a bold header text to highlight sections. -FormControl::input(string $label, string $placeholder = "", string $default = "") // Text input field. Returns user input as a string. +FormControl::input(string $label, string $placeholder = "", string $default = "", ?string $tooltip = null) // Text input field. Returns user input as a string. FormControl::label(string $label) // Static text label, for descriptions or instructions. -FormControl::slider(string $label, float $min, float $max, float $step = 0.0, float $default = 0.0) // A numeric slider. Returns a float value. -FormControl::stepSlider(string $label, array $steps, ?string $default = null) // A discrete slider with string options. Returns a selected step. -FormControl::toggle(string $label, bool $default = false) // A boolean toggle (checkbox). Returns true/false. +FormControl::slider(string $label, float $min, float $max, float $step = 0.0, float $default = 0.0, ?string $tooltip = null) // A numeric slider. Returns a float value. +FormControl::stepSlider(string $label, array $steps, ?string $default = null, ?string $tooltip = null) // A discrete slider with string options. Returns a selected step. +FormControl::toggle(string $label, bool $default = false, ?string $tooltip = null) // A boolean toggle (checkbox). Returns true/false. ``` ### Menu Available elements ```php MenuElement::button(string $text) // One user selectable button with text -MenuElement::buttonWithImagePath(string $text) // Please specify the path within the resource pack -MenuElement::buttonWithImageUrl(string $text) // This is obtained by the client, but there is a bug. -MenuElement::divider(string $text) // Adds a horizontal divider to visually separate form sections. -MenuElement::header(string $text) // Adds a bold header text to highlight sections. -MenuElement::label(string $text) // Static text label, for descriptions or instructions. +MenuElement::buttonWithImagePath(string $text, string $path) // Please specify the path within the resource pack +MenuElement::buttonWithImageUrl(string $text, string $url) // This is obtained by the client, but there is a bug. +MenuElement::divider() // Adds a horizontal divider to visually separate menu sections. +MenuElement::header(string $label) // Adds a bold header text to highlight sections. +MenuElement::label(string $label) // Static text label, for descriptions or instructions. ``` ## ⚠️ Notes on `getOptions()` @@ -1117,6 +1137,8 @@ public function getOptions(): array { } ``` +An empty `getOptions()` contributes no controls or menu elements. For menus, make sure the final assembled menu still contains at least one interactable `MenuElement` if you expect `sendMenuAsync()` to return normally. + --- ## ⚠️ What to be aware of if you don't call request first in your generator @@ -1736,7 +1758,7 @@ Coroutine suspension provided by AwaitGenerator keeps this object graph alive un As a result, object lifetime is primarily determined by coroutine lifetime rather than by traditional object ownership patterns. -# 1.1.0 Futures +# 1.1.0 Features ## Nested Options What should I do if I'm using object-oriented design, and I want to add elements but can't access the parent class? @@ -1890,7 +1912,7 @@ class ConfirmInputForm extends FormOptions{ } ``` -## 1.3.0 Future +## 1.3.0 Feature ### schedule Want to use some await before sending a request? Now you can with schedule() in 1.3.0! @@ -1927,7 +1949,7 @@ class HpBasedFoodOptions extends MenuOptions{ } ``` -## 2.0.0 Future +## 2.0.0 Features ### A new abstract has been added: userDispose Each option must implement userDispose to handle garbage collection ```php @@ -1939,7 +1961,7 @@ protected function userDispose() : void{ ### Memory leak fixed Fixed gc leak (memory leak) when form is abandoned   -## 3.0.0 Future 📌 +## 3.0.0 Features 📌 * ➕ **AwaitFormOptionsParentException** has been added. This exception receives all non-fatal exceptions that occur during normal execution and may propagate to the parent coroutine. @@ -2012,8 +2034,8 @@ Fixed gc leak (memory leak) when form is abandoned   * ❌ The `neverRejects` parameter of the standalone functions `sendMenu` and `sendForm` has been removed for the same reasons described above. -# 4.0.0 Future -- dump AwaitForm 1.0.0 +# 4.0.0 Features +- Bumped AwaitForm to 1.0.0 - The button class has been replaced by \cosmicpe\awaitform\MenuElement - The number of elements available in MenuElement has increased - phpdoc has been added for some APIs @@ -2033,4 +2055,4 @@ Fixed gc leak (memory leak) when form is abandoned   Invalid request tuples now stop as `AwaitFormOptionsExpectedCrashException` instead of continuing through parent-side assembly. - 🔤 Added the correctly spelled `AwaitFormOptionsException` base class while keeping the existing `AwaitFormOptionsExcption` typo for compatibility. - 🧪 Added PHPUnit coverage for request collection, menu race ID mapping, return storage, finalizer priority, rejection, abort, and invalid solve handling. -- phpdoc has been improved with GPT 5.5 +- phpdoc has been improved. diff --git a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php index 0f0a5ee..b4ba823 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php @@ -32,8 +32,15 @@ final private function __construct(){ } /** - * @param array $options - * @throws \Throwable + * Starts sendFormAsync() as a standalone coroutine. + * + * Player rejection and parent-level form failures are swallowed. Developer + * errors, including invalid option types and invalid request payloads, are not + * swallowed. + * + * @param array $options + * @throws AwaitFormOptionsExpectedCrashException + * @throws \Throwable Other developer errors and user-code exceptions are not swallowed. */ public static function sendForm(Player $player, string $title, array $options) : void{ Await::f2c(function() use ($options, $title, $player){ @@ -81,8 +88,10 @@ public static function sendForm(Player $player, string $title, array $options) : * Developer mistakes such as invalid option types or invalid request values * are reported as AwaitFormOptionsExpectedCrashException subclasses. * - * @param array $options Awaitable form option providers - * @throws FormValidationException|AwaitFormOptionsParentException|AwaitFormOptionsInvalidValueException I don't write \throwable because it's enough to piss off phpstan :< + * @param array $options Awaitable form option providers + * @return \Generator>> + * @throws AwaitFormOptionsExpectedCrashException|AwaitFormOptionsParentException + * @throws \Throwable Other developer errors and user-code exceptions are not wrapped. */ public static function sendFormAsync(Player $player, string $title, array $options) : \Generator{ $bridge = new RequestResponseBridge(); @@ -105,7 +114,7 @@ public static function sendFormAsync(Player $player, string $title, array $optio Utils::validateArrayValueType($forms, static function(\Generator|FormOptions $value) : void{ }); }catch(\TypeError){ - throw new AwaitFormOptionsInvalidValueException($option::class . "::getOptions() must return an array(list) of \Generator, see also AwaitFormOptions::sendFromAsync()"); + throw new AwaitFormOptionsInvalidValueException($option::class . "::getOptions() must return an array of \Generator or nested FormOptions, see also AwaitFormOptions::sendFormAsync()"); } foreach($forms as $key1 => $item){ @@ -160,8 +169,9 @@ public static function sendFormAsync(Player $player, string $title, array $optio /** @phpstan-ignore function.impossibleType */ if(!is_scalar($key) || is_object($key)){ //HACK: Making backtraces useful - $bridge->reject($id, new AwaitFormOptionsExpectedCrashException("key must be scalar, see also AwaitFormOptions::sendFormAsync()")); - return []; + $exception = new AwaitFormOptionsExpectedCrashException("key must be scalar, see also AwaitFormOptions::sendFormAsync()"); + $bridge->reject($id, $exception); + throw $exception; } $keys[] = $key; $options[] = $item; @@ -207,8 +217,15 @@ public static function sendFormAsync(Player $player, string $title, array $optio } /** - * @param array $buttons - * @throws \Throwable + * Starts sendMenuAsync() as a standalone coroutine. + * + * Player rejection and parent-level form failures are swallowed. Developer + * errors, including invalid option types and invalid request payloads, are not + * swallowed. + * + * @param array $buttons + * @throws AwaitFormOptionsExpectedCrashException + * @throws \Throwable Other developer errors and user-code exceptions are not swallowed. */ public static function sendMenu(Player $player, string $title, string $content, array $buttons) : void{ Await::f2c(function() use ($content, $buttons, $title, $player){ @@ -252,9 +269,10 @@ public static function sendMenu(Player $player, string $title, string $content, * Developer mistakes such as invalid option types or invalid request values * are reported as AwaitFormOptionsExpectedCrashException subclasses. * - * @param array $buttons Awaitable menu option providers - * @return \Generator - * @throws FormValidationException|AwaitFormOptionsExpectedCrashException|AwaitFormOptionsParentException I don't write \throwable because it's enough to piss off phpstan :< + * @param array $buttons Awaitable menu option providers + * @return \Generator + * @throws AwaitFormOptionsExpectedCrashException|AwaitFormOptionsParentException + * @throws \Throwable Other developer errors and user-code exceptions are not wrapped. */ public static function sendMenuAsync(Player $player, string $title, string $content, array $buttons) : \Generator{ $bridge = new RequestResponseBridge(); @@ -279,7 +297,7 @@ public static function sendMenuAsync(Player $player, string $title, string $cont Utils::validateArrayValueType($array, static function(\Generator|MenuOptions $value) : void{ }); }catch(\TypeError){ - throw new AwaitFormOptionsInvalidValueException($option::class . "::getOptions() must return an array(list) of \Generator, see also AwaitFormOptions::sendMenuAsync()"); + throw new AwaitFormOptionsInvalidValueException($option::class . "::getOptions() must return an array of \Generator or nested MenuOptions, see also AwaitFormOptions::sendMenuAsync()"); } foreach($array as $key2 => $item){ @@ -309,7 +327,7 @@ public static function sendMenuAsync(Player $player, string $title, string $cont // 各 MenuOptions に紐づくボタン群を構築 $counter = 0; $index = []; // id => [start, end, keys] - $flatButtons = []; // 表示用に flatten された Button[] + $flatButtons = []; // 表示用に flatten された MenuElement[] foreach(yield from $bridge->getAllExpected() as $id => $array){ $keys = []; @@ -320,9 +338,9 @@ public static function sendMenuAsync(Player $player, string $title, string $cont if(!(array_is_list($item) && count($item) === 2)){ if(array_is_list($item) && count($item) !== 2){ $exception = new AwaitFormOptionsExpectedCrashException( - "The request value must be a 2-element list array [Button, key], but an array with " . count($item) . " element(s) was given. \n" . + "The request value must be a 2-element list array [MenuElement, key], but an array with " . count($item) . " element(s) was given. \n" . " (key: " . $key . "). " . - "Ensure that your form returns an array like [Button, SelectedKey]. " . + "Ensure that your menu returns an array like [MenuElement, SelectedValue]. " . "See also: AwaitFormOptions::sendMenuAsync()" ); $bridge->reject($id, $exception); diff --git a/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php b/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php index e340a4e..a5c3935 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php @@ -63,7 +63,7 @@ abstract protected function userDispose() : void; * * Higher priority values are resumed first when the parent calls tryFinalize(). * - * @return \Generator + * @return \Generator */ final protected function finalize(int $priority = 0) : \Generator{ yield from $this->bridge->finalize($priority); @@ -94,8 +94,8 @@ final protected function schedule() : void{ * treated as an expected crash because the parent request accounting would no * longer be reliable. * - * @param array{FormControl|MenuElement, mixed}|array|list> $value - * @return \Generator + * @param array{FormControl|MenuElement, mixed}|array $value + * @return \Generator * @throws AwaitFormOptionsChildException|AwaitFormOptionsExpectedCrashException */ final protected function request(array $value) : \Generator{ diff --git a/src/DaisukeDaisuke/AwaitFormOptions/FormOptions.php b/src/DaisukeDaisuke/AwaitFormOptions/FormOptions.php index ed298ea..6a8bdac 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/FormOptions.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/FormOptions.php @@ -8,7 +8,7 @@ abstract class FormOptions{ use FormBridgeTrait; /** - * @return array<\Generator>|array + * @return array */ abstract public function getOptions() : array; } diff --git a/src/DaisukeDaisuke/AwaitFormOptions/MenuOptions.php b/src/DaisukeDaisuke/AwaitFormOptions/MenuOptions.php index f575730..4afff81 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/MenuOptions.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/MenuOptions.php @@ -8,7 +8,7 @@ abstract class MenuOptions{ use FormBridgeTrait; /** - * @return array<\Generator>|array + * @return array */ abstract public function getOptions() : array; } diff --git a/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php b/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php index c7dfa86..4d1eb24 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php @@ -4,7 +4,6 @@ namespace DaisukeDaisuke\AwaitFormOptions; -use DaisukeDaisuke\AwaitFormOptions\exception\AwaitFormOptionsAbortException; use DaisukeDaisuke\AwaitFormOptions\exception\AwaitFormOptionsChildException; use DaisukeDaisuke\AwaitFormOptions\exception\AwaitFormOptionsException; use SOFe\AwaitGenerator\Await; @@ -21,7 +20,7 @@ class RequestResponseBridge{ private int $reservesId = 0; - /** @var array>> Request payload channels keyed by request ID. */ + /** @var array>>> Request payload channels keyed by request ID. */ private array $pendingRequest = []; /** @var array Response resolvers keyed by request ID. */ @@ -37,6 +36,8 @@ class RequestResponseBridge{ private array $reserves = []; /** Request ID currently being solved; used to map menu race returns back to the selected request. */ private ?int $solvingRequestId = null; + /** Most recent solved request ID; used when a selected menu generator completes after finalize(). */ + private ?int $lastSolvedRequestId = null; /** Whether a menu race has already stored the selected generator's return value. */ private bool $raceResolved = false; @@ -47,9 +48,9 @@ class RequestResponseBridge{ * If $reserved is provided, this request fulfills the matching schedule() * reservation and unblocks getAllExpected(). * - * @param list|list $value 要求する値 + * @param list>|list $value 要求する値 * @param ?int $reserved 予約id - * @return \Generator 応答値 + * @return \Generator 応答値 * * @throws AwaitFormOptionsChildException */ @@ -91,7 +92,7 @@ public function schedule() : int{ * Otherwise this generator intentionally remains suspended because the parent * cannot safely assemble the form/menu with an unknown future request. * - * @return \Generator + * @return \Generator>>> */ public function getAllExpected() : \Generator{ $result = []; @@ -117,6 +118,8 @@ public function getAllExpected() : \Generator{ * * During the resolver call, $solvingRequestId is set so a completing menu race * can store the selected generator return value under the actual request ID. + * $lastSolvedRequestId remains available after the resolver returns, because a + * selected menu generator may suspend at finalize() before it returns. * * @param int $id 応答先ID * @param mixed $value 応答する値 @@ -130,6 +133,7 @@ public function solve(int $id, mixed $value) : void{ unset($this->rejects[$id], $this->pendingSend[$id]); $this->solvingRequestId = $id; + $this->lastSolvedRequestId = $id; try{ $resolve($value); }finally{ @@ -253,9 +257,9 @@ public function reject(int $id, AwaitFormOptionsException $throwable) : bool{ * The stored shape is $returns[$id][$owenr]. * * @param int $id Unique identifier for storing the results. - * @param int|string $owenr Identifier for the owner of the result. - * @param array<\Generator> $array An array of tasks (generators) to be processed asynchronously. - * @param list|null $keys Optional array of keys for mapping the results. + * @param int|string $owenr Identifier for the owner of the result. + * @param array> $array An array of tasks (generators) to be processed asynchronously. + * @param list|null $keys Optional array of keys for mapping the results. */ public function all(int $id, int|string $owenr, array $array, ?array $keys = []) : void{ Await::f2c(function() use ($owenr, $id, $array, $keys){ @@ -278,7 +282,7 @@ public function all(int $id, int|string $owenr, array $array, ?array $keys = []) * return values are ignored. * * @param int $id Retained for call-site compatibility; not used for result mapping. - * @param array<\Generator> $array An array of child generators to execute in a race. + * @param array> $array An array of child generators to execute in a race. * @throws \LogicException If a generator completes without being resumed by solve(). * * Note: aborting losing generators may propagate non-child exceptions thrown @@ -292,11 +296,12 @@ public function race(int $id, array $array) : void{ if($this->raceResolved){ return; } - if($this->solvingRequestId === null){ + $requestId = $this->solvingRequestId ?? $this->lastSolvedRequestId; + if($requestId === null){ throw new \LogicException("Menu race completed without a solved request ID"); } $this->raceResolved = true; - $this->returns[$this->solvingRequestId] = $result; + $this->returns[$requestId] = $result; $this->abortAll(); }); } @@ -310,7 +315,7 @@ public function race(int $id, array $array) : void{ * * @param int $id Unique identifier for storing the result. * @param int|string $owenr The key used to store the generator's return value. - * @param \Generator $generator The child generator to execute. + * @param \Generator $generator The child generator to execute. */ public function one(int $id, int|string $owenr, \Generator $generator) : void{ Await::f2c(function() use ($owenr, $id, $generator){ @@ -344,7 +349,7 @@ public function getReturns() : array{ * @param int $priority Priority level for the finalization process (default is 0). * Higher numbers indicate higher processing priority. * - * @return \Generator Yields until the finalization process is completed. + * @return \Generator Yields until the finalization process is completed. */ public function finalize(int $priority = 0) : \Generator{ $obj = new Channel(); @@ -377,6 +382,6 @@ public function tryFinalize() : void{ * invalid because all coroutine coordination arrays have been unset. */ public function dispose() : void{ - unset($this->pendingRequest, $this->pendingSend, $this->rejects, $this->returns, $this->finalizeList, $this->reserves, $this->reservesId, $this->nextId, $this->solvingRequestId, $this->raceResolved); + unset($this->pendingRequest, $this->pendingSend, $this->rejects, $this->returns, $this->finalizeList, $this->reserves, $this->reservesId, $this->nextId, $this->solvingRequestId, $this->lastSolvedRequestId, $this->raceResolved); } } diff --git a/src/DaisukeDaisuke/AwaitFormOptions/exception/AwaitFormOptionsException.php b/src/DaisukeDaisuke/AwaitFormOptions/exception/AwaitFormOptionsException.php new file mode 100644 index 0000000..24895b8 --- /dev/null +++ b/src/DaisukeDaisuke/AwaitFormOptions/exception/AwaitFormOptionsException.php @@ -0,0 +1,9 @@ + "late:selected"], $bridge->getReturns()); } + public function testMenuRaceStoresReturnAfterSelectedGeneratorFinalizes() : void{ + $bridge = new RequestResponseBridge(); + + $selected = (function() use ($bridge) : Generator{ + yield from $bridge->request([MenuElement::button("selected")]); + yield from $bridge->finalize(); + return "selected"; + })(); + + $loser = (function() use ($bridge) : Generator{ + try{ + yield from $bridge->request([MenuElement::button("loser")]); + return "loser"; + }catch(AwaitFormOptionsChildException){ + return "loser-aborted"; + } + })(); + + $bridge->race(0, [$selected, $loser]); + + $requests = null; + Await::f2c(function() use ($bridge, &$requests) : Generator{ + $requests = yield from $bridge->getAllExpected(); + }); + + self::assertSame([0, 1], array_keys($requests)); + + $bridge->solve(0, "selected"); + + self::assertSame([], $bridge->getReturns()); + + $bridge->tryFinalize(); + + self::assertSame([0 => "selected"], $bridge->getReturns()); + } + public function testOneStoresGeneratorReturnUnderOwnerKey() : void{ $bridge = new RequestResponseBridge(); From 67c4d1de6752f29833ea5bcdc2e2b4291c4493cb Mon Sep 17 00:00:00 2001 From: DaisukeDaisuke <17798680+DaisukeDaisuke@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:28:22 +0900 Subject: [PATCH 2/5] v5.0.0 --- README.md | 12 ++++++++++++ .../AwaitFormOptions/AwaitFormOptions.php | 4 ---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1c74c8..c42750b 100644 --- a/README.md +++ b/README.md @@ -2056,3 +2056,15 @@ Fixed gc leak (memory leak) when form is abandoned   - 🔤 Added the correctly spelled `AwaitFormOptionsException` base class while keeping the existing `AwaitFormOptionsExcption` typo for compatibility. - 🧪 Added PHPUnit coverage for request collection, menu race ID mapping, return storage, finalizer priority, rejection, abort, and invalid solve handling. - phpdoc has been improved. + +# 5.0.0 Changelog +- 🐛 Fixed menu race return-value mapping when the selected generator resumes after `finalize()`. + The selected request ID is now retained until the selected generator has returned. +- 🛡️ Form request keys now always fail as `AwaitFormOptionsExpectedCrashException` when they are not scalar. + This prevents invalid form keys from being converted into an empty successful result. +- 🔤 Added a correctly named `AwaitFormOptionsException.php` source file while preserving the deprecated `AwaitFormOptionsExcption` class for compatibility. +- 🧩 Clarified `request()` payload shapes in the README. + Form requests use `FormControl` values or `[FormControl, scalar-key]`; menu requests use `MenuElement` values or `[MenuElement, mixed-value]`. +- 📝 Updated README examples and API notes for separate option instances, menu return values, removed `throwExceptionInCaller`, empty menu contributions, and AwaitForm 1.0.0 element signatures. +- 🧪 Added PHPUnit coverage for selected menu generators that return only after `finalize()`. +- Improved phpdoc for option arrays, generator return types, bridge payloads, and exception references without requiring callers to catch `\Throwable`. diff --git a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php index b4ba823..93394ee 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php @@ -40,7 +40,6 @@ final private function __construct(){ * * @param array $options * @throws AwaitFormOptionsExpectedCrashException - * @throws \Throwable Other developer errors and user-code exceptions are not swallowed. */ public static function sendForm(Player $player, string $title, array $options) : void{ Await::f2c(function() use ($options, $title, $player){ @@ -91,7 +90,6 @@ public static function sendForm(Player $player, string $title, array $options) : * @param array $options Awaitable form option providers * @return \Generator>> * @throws AwaitFormOptionsExpectedCrashException|AwaitFormOptionsParentException - * @throws \Throwable Other developer errors and user-code exceptions are not wrapped. */ public static function sendFormAsync(Player $player, string $title, array $options) : \Generator{ $bridge = new RequestResponseBridge(); @@ -225,7 +223,6 @@ public static function sendFormAsync(Player $player, string $title, array $optio * * @param array $buttons * @throws AwaitFormOptionsExpectedCrashException - * @throws \Throwable Other developer errors and user-code exceptions are not swallowed. */ public static function sendMenu(Player $player, string $title, string $content, array $buttons) : void{ Await::f2c(function() use ($content, $buttons, $title, $player){ @@ -272,7 +269,6 @@ public static function sendMenu(Player $player, string $title, string $content, * @param array $buttons Awaitable menu option providers * @return \Generator * @throws AwaitFormOptionsExpectedCrashException|AwaitFormOptionsParentException - * @throws \Throwable Other developer errors and user-code exceptions are not wrapped. */ public static function sendMenuAsync(Player $player, string $title, string $content, array $buttons) : \Generator{ $bridge = new RequestResponseBridge(); From e1483c9724cdb42f3ab687a270d9bbc50222e18f Mon Sep 17 00:00:00 2001 From: DaisukeDaisuke <17798680+DaisukeDaisuke@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:47:16 +0900 Subject: [PATCH 3/5] v5.0.1 --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c42750b..7575d80 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ An option-driven form handler framework built on AwaitForm for `pmmp` plugins. Designed to modularize complex user interactions and support clean, reusable, async code. +## Final API Contract + +- This document describes the final, archived 4.x implementation. +- Each `FormOptions` or `MenuOptions` instance is single-use. +- Each child generator returned by `getOptions()` must eventually call `request()` exactly once. +- `getOptions()` may return an empty array. +- A child that suspends before reaching `request()` must call `schedule()` before that suspension. +- Only one level of same-kind nested options is supported. +- `FormOptions` accepts only `FormControl` values. +- `MenuOptions` accepts only `MenuElement` values. +- `sendFormAsync()` collects all child-generator return values. +- `sendMenuAsync()` returns only the selected child generator's return value. +- `AwaitFormOptionsExpectedCrashException` represents developer misuse and should normally not be caught. +- If this documentation conflicts with the final tagged source code or tests, the source code and tests are authoritative. + ## Requirements @@ -34,16 +49,25 @@ AwaitFormOptions is designed to simplify complex form workflows and improve deve --- +> [!WARNING] +> This project is archived and is no longer actively maintained. +> +> Development for PocketMine-MP has ended as of 2026. +> This documentation describes the final 4.x implementation contained in this repository. +> Compatibility with future versions of PocketMine-MP, AwaitForm, PHP, or await-generator is not guaranteed. +> Issues and pull requests may not receive a response. + > [!NOTE] > When using an older version, please refer to the README for that specific version -> This README also serves as the specification and documentation, and is continuously updated to reflect the latest version. It does not support older versions. +> This README serves as the specification and documentation for the final repository state. It is frozen and does not document older versions. > To view documentation for older versions, please refer to the corresponding tags. > > Support Status -> 1.x series: End of life, There is a significant memory leak -> 2.x series: End of life, -> 3.x series: Archived and issues are supported -> 4.x series: In development +> 1.x: End of life; contains a significant memory leak +> 2.x: End of life +> 3.x: End of life +> 4.x: series: In development +> 5.x: Final archived series. no active maintenance ## Why? @@ -1179,7 +1203,7 @@ While improvements may be considered in the future, this area is not a developme ## ❓How about PMServerUI? PMServerUI (https://github.com/DavyCraft648/PMServerUI) is a great library for beginners who want to create simple and clean UI menus with minimal effort. Its straightforward API makes it easy to build forms quickly. -However, AwaitFormOptions provides a more powerful and flexible system that supports deeply nested options, persistent context between form steps, and asynchronous flow control. +However, AwaitFormOptions provides a more powerful and flexible system that supports composable option groups with one level of nesting, object-held context during a single form or menu execution, and asynchronous flow control. While it is technically possible to recreate something like PMServerUI using AwaitFormOptions, the reverse is not true. PMServerUI cannot handle advanced patterns such as dynamic generator-based form logic, branching flows, or contextual state within multi-step UIs. ## ❓Why isn't `Dialog` supported by `AwaitFormOptions`? From 99f14a77da1cfa5ac22ec069777c59559c231440 Mon Sep 17 00:00:00 2001 From: DaisukeDaisuke <17798680+DaisukeDaisuke@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:48:56 +0900 Subject: [PATCH 4/5] v5.0.1 rev2 --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7575d80..ea59b37 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Designed to modularize complex user interactions and support clean, reusable, as ## Final API Contract -- This document describes the final, archived 4.x implementation. +- This document describes the final, archived 5.x implementation. - Each `FormOptions` or `MenuOptions` instance is single-use. - Each child generator returned by `getOptions()` must eventually call `request()` exactly once. - `getOptions()` may return an empty array. @@ -53,7 +53,7 @@ AwaitFormOptions is designed to simplify complex form workflows and improve deve > This project is archived and is no longer actively maintained. > > Development for PocketMine-MP has ended as of 2026. -> This documentation describes the final 4.x implementation contained in this repository. +> This documentation describes the final 5.x implementation contained in this repository. > Compatibility with future versions of PocketMine-MP, AwaitForm, PHP, or await-generator is not guaranteed. > Issues and pull requests may not receive a response. @@ -61,13 +61,13 @@ AwaitFormOptions is designed to simplify complex form workflows and improve deve > When using an older version, please refer to the README for that specific version > This README serves as the specification and documentation for the final repository state. It is frozen and does not document older versions. > To view documentation for older versions, please refer to the corresponding tags. -> -> Support Status -> 1.x: End of life; contains a significant memory leak -> 2.x: End of life -> 3.x: End of life -> 4.x: series: In development -> 5.x: Final archived series. no active maintenance +> +> Support Status +> 1.x: End of life; contains a significant memory leak +> 2.x: End of life +> 3.x: End of life +> 4.x: series: In development +> 5.x: Final archived series. no active maintenance ## Why? From 0f4e516e2017ea8796a8a158896dc5066c384663 Mon Sep 17 00:00:00 2001 From: DaisukeDaisuke <17798680+DaisukeDaisuke@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:58:35 +0900 Subject: [PATCH 5/5] v5.1.0 --- README.md | 36 +++++ .../AwaitFormOptions/AwaitFormOptions.php | 37 +++-- .../AwaitFormOptions/FormBridgeTrait.php | 19 ++- .../RequestResponseBridge.php | 131 +++++++++++++----- tests/RequestResponseBridgeTest.php | 80 +++++++++++ 5 files changed, 257 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ea59b37..5f48569 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Designed to modularize complex user interactions and support clean, reusable, as - `MenuOptions` accepts only `MenuElement` values. - `sendFormAsync()` collects all child-generator return values. - `sendMenuAsync()` returns only the selected child generator's return value. +- After a menu child request is solved, it must complete synchronously or wait only once on `finalize()`; `sendMenuAsync()` does not await arbitrary later asynchronous work. - `AwaitFormOptionsExpectedCrashException` represents developer misuse and should normally not be caught. - If this documentation conflicts with the final tagged source code or tests, the source code and tests are authoritative. @@ -2092,3 +2093,38 @@ Fixed gc leak (memory leak) when form is abandoned   - 📝 Updated README examples and API notes for separate option instances, menu return values, removed `throwExceptionInCaller`, empty menu contributions, and AwaitForm 1.0.0 element signatures. - 🧪 Added PHPUnit coverage for selected menu generators that return only after `finalize()`. - Improved phpdoc for option arrays, generator return types, bridge payloads, and exception references without requiring callers to catch `\Throwable`. + + +## 5.1.0 + +### Fixed + +- Fixed menu race result attribution so that only the child generator owning the selected request can provide the return value. +- Fixed cancelled menu children being able to complete normally and interfere with the selected race result. +- Fixed late menu-child completion callbacks accessing bridge state after the parent operation had already closed or disposed it. +- Fixed a re-entrancy issue where pending request rejectors could be invoked more than once during synchronous coroutine completion. +- Fixed `finalize()` registrations created while finalization was already in progress being discarded. +- Fixed concurrent reuse of the same `FormOptions` or `MenuOptions` instance overwriting its active request bridge. + +### Changed + +- Added an explicit lifecycle state machine for menu races: inactive, open, selected, and closed. +- Menu request IDs are now associated with their owning race child, including requests registered after `schedule()`. +- Bridge cleanup now rejects all remaining child requests before detaching internal state. +- Cleanup is now best-effort: all option instances are disposed even if one `userDispose()` implementation throws. +- `dispose()` is now idempotent for both option instances and the request-response bridge. +- Cleanup rethrows the first unexpected exception after all remaining resources have been released. +- `rejectsAll()` and `abortAll()` now use the shared bridge-closing path. + +### Documentation + +- Documented that a selected menu child must complete synchronously after `request()` resumes, or suspend only once through `finalize()`. +- Clarified that `sendMenuAsync()` does not wait for arbitrary asynchronous work started after the selected request has been solved. +- Added final API contract details covering single-use option instances, nesting limits, request payload types, and return-value behavior. + +### Tests + +- Added coverage for menu children that catch cancellation and return normally. +- Added coverage for late selected-child completion after bridge closure and disposal. +- Added coverage preventing one option instance from being attached to multiple bridges. +- Added coverage ensuring `finalize()` registrations made during finalization are preserved for the next finalization pass. \ No newline at end of file diff --git a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php index 93394ee..bbafa1d 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/AwaitFormOptions.php @@ -31,6 +31,33 @@ final private function __construct(){ } + /** + * Releases every child before detaching the bridge. Cleanup is deliberately + * best-effort: one faulty userDispose() hook cannot strand the remaining + * options or their child coroutines. + * + * @param list $options + */ + private static function cleanup(RequestResponseBridge $bridge, array $options) : void{ + $firstException = null; + try{ + $bridge->close(new AwaitFormOptionsChildException("", AwaitFormOptionsChildException::ERR_COROUTINE_ABORTED)); + }catch(\Throwable $exception){ + $firstException = $exception; + } + foreach($options as $option){ + try{ + $option->dispose(); + }catch(\Throwable $exception){ + $firstException ??= $exception; + } + } + $bridge->dispose(); + if($firstException !== null){ + throw $firstException; + } + } + /** * Starts sendFormAsync() as a standalone coroutine. * @@ -205,10 +232,7 @@ public static function sendFormAsync(Player $player, string $title, array $optio throw new AwaitFormOptionsExpectedCrashException("Unhandled AwaitFormOptionsChildException", $exception->getCode(), $exception); } }finally{ - foreach($needDispose as $item){ - $item->dispose(); - } - $bridge->dispose(); + self::cleanup($bridge, $needDispose); unset($bridge, $needDispose); } //This code path should be unreachable :( @@ -390,10 +414,7 @@ public static function sendMenuAsync(Player $player, string $title, string $cont // 該当しなかった場合はフォーム不正とみなす throw new AwaitFormOptionsParentException("An invalid MenuElement selection was made", AwaitFormOptionsParentException::ERR_VERIFICATION_FAILED); }finally{ - foreach($needDispose as $item){ - $item->dispose(); - } - $bridge->dispose(); + self::cleanup($bridge, $needDispose); unset($bridge, $needDispose, $buttons, $flatButtons, $flatOptions, $index, $keys, $returns); } } diff --git a/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php b/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php index a5c3935..6abda33 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/FormBridgeTrait.php @@ -21,20 +21,22 @@ trait FormBridgeTrait{ private bool $requested = false; private ?int $reservesId = null; private bool $disposed = false; + private bool $attached = false; /** * Attaches the shared request/response bridge to this option instance. * - * Option instances are single-use. Once dispose() has been called, setting a new - * bridge would allow stale coroutine state to be reused, so it is rejected early. + * Option instances are single-use. Attaching the same instance while it is + * already active would overwrite its bridge and corrupt both parent coroutines. * * @internal * @throws AwaitFormOptionsInvalidValueException */ final public function setBridge(RequestResponseBridge $bridge) : void{ - if($this->isDisposed()){ + if($this->isDisposed() || $this->attached){ throw new AwaitFormOptionsInvalidValueException("Option reuse detected, class: " . static::class); } + $this->attached = true; $this->bridge = $bridge; } @@ -45,9 +47,16 @@ final public function setBridge(RequestResponseBridge $bridge) : void{ * @internal */ final public function dispose() : void{ + if($this->isDisposed()){ + return; + } + $this->setDisposed(true); - unset($this->bridge, $this->reservesId); - $this->userDispose(); + try{ + $this->userDispose(); + }finally{ + unset($this->bridge, $this->reservesId); + } } /** diff --git a/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php b/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php index 4d1eb24..ed55aed 100644 --- a/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php +++ b/src/DaisukeDaisuke/AwaitFormOptions/RequestResponseBridge.php @@ -16,6 +16,12 @@ use cosmicpe\awaitform\MenuElement; class RequestResponseBridge{ + private const RACE_INACTIVE = 0; + private const RACE_OPEN = 1; + private const RACE_SELECTED = 2; + private const RACE_CLOSED = 3; + /** Whether dispose() has detached the bridge from its parent operation. */ + private bool $disposed = false; private int $nextId = 0; private int $reservesId = 0; @@ -34,12 +40,16 @@ class RequestResponseBridge{ private array $finalizeList = []; /** @var array> Future request reservations keyed by reservation ID. */ private array $reserves = []; - /** Request ID currently being solved; used to map menu race returns back to the selected request. */ - private ?int $solvingRequestId = null; - /** Most recent solved request ID; used when a selected menu generator completes after finalize(). */ - private ?int $lastSolvedRequestId = null; - /** Whether a menu race has already stored the selected generator's return value. */ - private bool $raceResolved = false; + /** Current lifecycle state of a menu race. */ + private int $raceState = self::RACE_INACTIVE; + /** Request ID selected by the menu, if a menu race has selected one. */ + private ?int $selectedRequestId = null; + /** Race child currently being started; used to associate its first request with it. */ + private ?int $startingRaceChildId = null; + /** @var array Request IDs keyed to their owning menu race child. */ + private array $raceChildByRequestId = []; + /** @var array Reservation IDs keyed to their owning menu race child. */ + private array $raceChildByReserveId = []; /** * Registers a request payload and suspends until the parent coroutine provides @@ -56,6 +66,10 @@ class RequestResponseBridge{ */ public function request(mixed $value, ?int $reserved = null) : \Generator{ $id = $this->nextId++; + $raceChildId = $this->startingRaceChildId ?? ($reserved === null ? null : ($this->raceChildByReserveId[$reserved] ?? null)); + if($raceChildId !== null){ + $this->raceChildByRequestId[$id] = $raceChildId; + } $this->pendingRequest[$id] = new Channel(); $this->pendingRequest[$id]->sendWithoutWait($value); @@ -80,6 +94,9 @@ public function request(mixed $value, ?int $reserved = null) : \Generator{ */ public function schedule() : int{ $id = $this->reservesId++; + if($this->startingRaceChildId !== null){ + $this->raceChildByReserveId[$id] = $this->startingRaceChildId; + } $this->reserves[$id] = new Channel(); return $id; } @@ -116,10 +133,9 @@ public function getAllExpected() : \Generator{ /** * Sends the parent response to a waiting child request. * - * During the resolver call, $solvingRequestId is set so a completing menu race - * can store the selected generator return value under the actual request ID. - * $lastSolvedRequestId remains available after the resolver returns, because a - * selected menu generator may suspend at finalize() before it returns. + * For menu races, the selected request ID is recorded before the resolver runs. + * This makes a synchronously completing child and a child that completes after + * finalize() use the same explicit winner identity. * * @param int $id 応答先ID * @param mixed $value 応答する値 @@ -132,13 +148,13 @@ public function solve(int $id, mixed $value) : void{ $resolve = $this->pendingSend[$id]; unset($this->rejects[$id], $this->pendingSend[$id]); - $this->solvingRequestId = $id; - $this->lastSolvedRequestId = $id; - try{ - $resolve($value); - }finally{ - $this->solvingRequestId = null; + if($this->raceState === self::RACE_OPEN){ + // Set the winner before resuming user code: resolve() can synchronously + // complete the generator and re-enter the race callback. + $this->raceState = self::RACE_SELECTED; + $this->selectedRequestId = $id; } + $resolve($value); } /** @@ -154,9 +170,7 @@ public function solve(int $id, mixed $value) : void{ * @param AwaitFormOptionsChildException $throwable The exception to pass to each waiting child. */ public function rejectsAll(AwaitFormOptionsChildException $throwable) : void{ - foreach($this->rejects as $id => $reject){ - $this->abort($id, $throwable); - } + $this->close($throwable); } /** @@ -166,8 +180,44 @@ public function rejectsAll(AwaitFormOptionsChildException $throwable) : void{ * exceptions thrown by child code are still allowed to propagate. */ public function abortAll() : void{ - foreach($this->rejects as $id => $reject){ - $this->abort($id); + $this->close(new AwaitFormOptionsChildException("", AwaitFormOptionsChildException::ERR_COROUTINE_ABORTED)); + } + + /** + * Closes the bridge and rejects every request that is still waiting. + * + * A closed menu race deliberately ignores completion callbacks from children + * that catch the rejection and return normally. Every reject handler is + * detached before invoking user code, so synchronous re-entry cannot reject a + * request twice. All handlers are attempted even if one child crashes. + * + * @throws \Throwable The first non-child exception thrown while releasing children. + */ + public function close(AwaitFormOptionsChildException $throwable) : void{ + if($this->disposed){ + return; + } + $this->raceState = self::RACE_CLOSED; + + $rejects = $this->rejects; + $this->rejects = []; + $this->pendingSend = []; + $firstException = null; + foreach($rejects as $reject){ + try{ + $reject($throwable); + }catch(AwaitException $exception){ + $previous = $exception->getPrevious(); + if(!$previous instanceof AwaitFormOptionsChildException){ + $firstException ??= $previous ?? $exception; + } + }catch(AwaitFormOptionsChildException){ + }catch(\Throwable $exception){ + $firstException ??= $exception; + } + } + if($firstException !== null){ + throw $firstException; } } @@ -291,19 +341,29 @@ public function all(int $id, int|string $owenr, array $array, ?array $keys = []) * @see solve Refer to the `solve` method for additional context on related functionality. */ public function race(int $id, array $array) : void{ + if($this->raceState !== self::RACE_INACTIVE){ + throw new \LogicException("A menu race has already been started"); + } + $this->raceState = self::RACE_OPEN; + $childId = 0; foreach($array as $generator){ - Await::g2c($generator, function($result) : void{ - if($this->raceResolved){ + $this->startingRaceChildId = $childId; + Await::g2c($generator, function($result) use ($childId) : void{ + if($this->disposed || $this->raceState === self::RACE_CLOSED){ return; } - $requestId = $this->solvingRequestId ?? $this->lastSolvedRequestId; - if($requestId === null){ + if($this->raceState !== self::RACE_SELECTED || $this->selectedRequestId === null){ throw new \LogicException("Menu race completed without a solved request ID"); } - $this->raceResolved = true; - $this->returns[$requestId] = $result; + if(($this->raceChildByRequestId[$this->selectedRequestId] ?? null) !== $childId){ + return; + } + $this->returns[$this->selectedRequestId] = $result; + $this->raceState = self::RACE_CLOSED; $this->abortAll(); }); + $this->startingRaceChildId = null; + $childId++; } } @@ -366,22 +426,27 @@ public function finalize(int $priority = 0) : \Generator{ * If no finalization reservations have been scheduled, the method performs no actions. */ public function tryFinalize() : void{ - krsort($this->finalizeList); // 高い優先度(数値が大きい)順に処理 - foreach($this->finalizeList as $group){ + $list = $this->finalizeList; + $this->finalizeList = []; + krsort($list); // 高い優先度(数値が大きい)順に処理 + foreach($list as $group){ foreach($group as $item){ $item->sendWithoutWait(null); } } - unset($this->finalizeList); } /** * Releases all internal bridge state. * - * This object is single-use after dispose(); accessing it again is intentionally - * invalid because all coroutine coordination arrays have been unset. + * This object is single-use after dispose(). Late menu-child completion + * callbacks are ignored so they cannot access detached coordination state. */ public function dispose() : void{ - unset($this->pendingRequest, $this->pendingSend, $this->rejects, $this->returns, $this->finalizeList, $this->reserves, $this->reservesId, $this->nextId, $this->solvingRequestId, $this->lastSolvedRequestId, $this->raceResolved); + if($this->disposed){ + return; + } + $this->disposed = true; + unset($this->pendingRequest, $this->pendingSend, $this->rejects, $this->returns, $this->finalizeList, $this->reserves, $this->reservesId, $this->nextId, $this->raceState, $this->selectedRequestId, $this->startingRaceChildId, $this->raceChildByRequestId, $this->raceChildByReserveId); } } diff --git a/tests/RequestResponseBridgeTest.php b/tests/RequestResponseBridgeTest.php index fb1a9e3..0887b7c 100644 --- a/tests/RequestResponseBridgeTest.php +++ b/tests/RequestResponseBridgeTest.php @@ -106,6 +106,68 @@ public function testMenuRaceStoresReturnAfterSelectedGeneratorFinalizes() : void self::assertSame([0 => "selected"], $bridge->getReturns()); } + public function testMenuRaceIgnoresChildrenThatReturnAfterCancellation() : void{ + $bridge = new RequestResponseBridge(); + + $child = static function() use ($bridge) : Generator{ + try{ + yield from $bridge->request([MenuElement::button("child")]); + }catch(AwaitFormOptionsChildException){ + return "cancelled"; + } + return "selected"; + }; + + $bridge->race(0, [$child(), $child()]); + $bridge->rejectsAll(new AwaitFormOptionsChildException("", 12345)); + + self::assertSame([], $bridge->getReturns()); + } + + public function testLateSelectedChildCompletionIsIgnoredAfterCloseAndDispose() : void{ + $bridge = new RequestResponseBridge(); + $resume = null; + + $child = static function() use ($bridge, &$resume) : Generator{ + yield from $bridge->request([MenuElement::button("selected")]); + yield from Await::promise(static function(\Closure $resolve) use (&$resume) : void{ + $resume = $resolve; + }); + return "late"; + }; + + $bridge->race(0, [$child()]); + $bridge->solve(0, "selected"); + self::assertIsCallable($resume); + + $bridge->close(new AwaitFormOptionsChildException("", AwaitFormOptionsChildException::ERR_COROUTINE_ABORTED)); + $bridge->dispose(); + + $error = null; + try{ + $resume(null); + }catch(\Throwable $exception){ + $error = $exception; + } + self::assertNull($error); + } + + public function testOptionCannotBeAttachedToTwoBridges() : void{ + $option = new class extends \DaisukeDaisuke\AwaitFormOptions\MenuOptions{ + public function getOptions() : array{ + return []; + } + + protected function userDispose() : void{ + } + }; + + $option->setBridge(new RequestResponseBridge()); + + $this->expectException(\DaisukeDaisuke\AwaitFormOptions\exception\AwaitFormOptionsInvalidValueException::class); + $option->setBridge(new RequestResponseBridge()); + } + public function testOneStoresGeneratorReturnUnderOwnerKey() : void{ $bridge = new RequestResponseBridge(); @@ -159,6 +221,24 @@ public function testFinalizeReleasesHigherPriorityWaitersFirst() : void{ self::assertSame(["high", "low"], $order); } + public function testFinalizePreservesRegistrationsMadeDuringFinalization() : void{ + $bridge = new RequestResponseBridge(); + $order = []; + + Await::g2c((function() use ($bridge, &$order) : Generator{ + yield from $bridge->finalize(); + $order[] = "first"; + yield from $bridge->finalize(); + $order[] = "second"; + })()); + + $bridge->tryFinalize(); + self::assertSame(["first"], $order); + + $bridge->tryFinalize(); + self::assertSame(["first", "second"], $order); + } + public function testRejectsAllPropagatesProvidedChildExceptionToWaitingRequests() : void{ $bridge = new RequestResponseBridge(); $caughtCode = null;