diff --git a/README.md b/README.md index 3b7aa14..75f154e 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,10 @@ A trivial implementation of timeouts for `Promise`s, built on top of [ReactPHP]( * [Usage](#usage) * [timeout()](#timeout) - * [Timeout cancellation](#timeout-cancellation) - * [Cancellation handler](#cancellation-handler) - * [Input cancellation](#input-cancellation) - * [Output cancellation](#output-cancellation) - * [Collections](#collections) * [resolve()](#resolve) - * [Resolve cancellation](#resolve-cancellation) * [reject()](#reject) - * [Reject cancellation](#reject-cancellation) * [TimeoutException](#timeoutexception) + * [getTimeout()](#gettimeout) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -51,19 +45,26 @@ Timer\timeout(…); ### timeout() -The `timeout(PromiseInterface $promise, $time, LoopInterface $loop = null)` function -can be used to *cancel* operations that take *too long*. -You need to pass in an input `$promise` that represents a pending operation and timeout parameters. -It returns a new `Promise` with the following resolution behavior: +The `timeout(PromiseInterface $promise, float $time, ?LoopInterface $loop = null): PromiseInterface` function can be used to +cancel operations that take *too long*. -* If the input `$promise` resolves before `$time` seconds, resolve the resulting promise with its fulfillment value. -* If the input `$promise` rejects before `$time` seconds, reject the resulting promise with its rejection value. -* If the input `$promise` does not settle before `$time` seconds, *cancel* the operation and reject the resulting promise with a [`TimeoutException`](#timeoutexception). +You need to pass in an input `$promise` that represents a pending operation +and timeout parameters. It returns a new promise with the following +resolution behavior: + +- If the input `$promise` resolves before `$time` seconds, resolve the + resulting promise with its fulfillment value. + +- If the input `$promise` rejects before `$time` seconds, reject the + resulting promise with its rejection value. + +- If the input `$promise` does not settle before `$time` seconds, *cancel* + the operation and reject the resulting promise with a [`TimeoutException`](#timeoutexception). Internally, the given `$time` value will be used to start a timer that will -*cancel* the pending operation once it triggers. -This implies that if you pass a really small (or negative) value, it will still -start a timer and will thus trigger at the earliest possible time in the future. +*cancel* the pending operation once it triggers. This implies that if you +pass a really small (or negative) value, it will still start a timer and will +thus trigger at the earliest possible time in the future. If the input `$promise` is already settled, then the resulting promise will resolve or reject immediately without starting a timer at all. @@ -117,95 +118,25 @@ React\Promise\Timer\timeout($promise, 10.0) ; ``` -#### Timeout cancellation - -As discussed above, the [`timeout()`](#timeout) function will *cancel* the -underlying operation if it takes *too long*. -This means that you can be sure the resulting promise will then be rejected -with a [`TimeoutException`](#timeoutexception). - -However, what happens to the underlying input `$promise` is a bit more tricky: -Once the timer fires, we will try to call -[`$promise->cancel()`](https://github.com/reactphp/promise#cancellablepromiseinterfacecancel) -on the input `$promise` which in turn invokes its [cancellation handler](#cancellation-handler). - -This means that it's actually up the input `$promise` to handle -[cancellation support](https://github.com/reactphp/promise#cancellablepromiseinterface). - -* A common use case involves cleaning up any resources like open network sockets or - file handles or terminating external processes or timers. - -* If the given input `$promise` does not support cancellation, then this is a NO-OP. - This means that while the resulting promise will still be rejected, the underlying - input `$promise` may still be pending and can hence continue consuming resources. - -See the following chapter for more details on the cancellation handler. - -#### Cancellation handler - -For example, an implementation for the above operation could look like this: - -```php -function accessSomeRemoteResource() -{ - return new Promise( - function ($resolve, $reject) use (&$socket) { - // this will be called once the promise is created - // a common use case involves opening any resources and eventually resolving - $socket = createSocket(); - $socket->on('data', function ($data) use ($resolve) { - $resolve($data); - }); - }, - function ($resolve, $reject) use (&$socket) { - // this will be called once calling `cancel()` on this promise - // a common use case involves cleaning any resources and then rejecting - $socket->close(); - $reject(new \RuntimeException('Operation cancelled')); - } - ); -} -``` - -In this example, calling `$promise->cancel()` will invoke the registered cancellation -handler which then closes the network socket and rejects the `Promise` instance. +As discussed above, the [`timeout()`](#timeout) function will take care of +the underlying operation if it takes *too long*. In this case, you can be +sure the resulting promise will always be rejected with a +[`TimeoutException`](#timeoutexception). On top of this, the function will +try to *cancel* the underlying operation. Responsibility for this +cancellation logic is left up to the underlying operation. -If no cancellation handler is passed to the `Promise` constructor, then invoking -its `cancel()` method it is effectively a NO-OP. -This means that it may still be pending and can hence continue consuming resources. +- A common use case involves cleaning up any resources like open network + sockets or file handles or terminating external processes or timers. -For more details on the promise cancellation, please refer to the -[Promise documentation](https://github.com/reactphp/promise#cancellablepromiseinterface). - -#### Input cancellation +- If the given input `$promise` does not support cancellation, then this is a + NO-OP. This means that while the resulting promise will still be rejected, + the underlying input `$promise` may still be pending and can hence continue + consuming resources -Irrespective of the timeout handling, you can also explicitly `cancel()` the -input `$promise` at any time. -This means that the `timeout()` handling does not affect cancellation of the -input `$promise`, as demonstrated in the following example: - -```php -$promise = accessSomeRemoteResource(); -$timeout = React\Promise\Timer\timeout($promise, 10.0); - -$promise->cancel(); -``` - -The registered [cancellation handler](#cancellation-handler) is responsible for -handling the `cancel()` call: - -* A described above, a common use involves resource cleanup and will then *reject* - the `Promise`. - If the input `$promise` is being rejected, then the timeout will be aborted - and the resulting promise will also be rejected. -* If the input `$promise` is still pending, then the timout will continue - running until the timer expires. - The same happens if the input `$promise` does not register a - [cancellation handler](#cancellation-handler). - -#### Output cancellation - -Similarily, you can also explicitly `cancel()` the resulting promise like this: +On top of this, the returned promise is implemented in such a way that it can +be cancelled when it is still pending. Cancelling a pending promise will +cancel the underlying operation. As discussed above, responsibility for this +cancellation logic is left up to the underlying operation. ```php $promise = accessSomeRemoteResource(); @@ -214,54 +145,11 @@ $timeout = React\Promise\Timer\timeout($promise, 10.0); $timeout->cancel(); ``` -Note how this looks very similar to the above [input cancellation](#input-cancellation) -example. Accordingly, it also behaves very similar. - -Calling `cancel()` on the resulting promise will merely try -to `cancel()` the input `$promise`. -This means that we do not take over responsibility of the outcome and it's -entirely up to the input `$promise` to handle cancellation support. - -The registered [cancellation handler](#cancellation-handler) is responsible for -handling the `cancel()` call: - -* As described above, a common use involves resource cleanup and will then *reject* - the `Promise`. - If the input `$promise` is being rejected, then the timeout will be aborted - and the resulting promise will also be rejected. -* If the input `$promise` is still pending, then the timout will continue - running until the timer expires. - The same happens if the input `$promise` does not register a - [cancellation handler](#cancellation-handler). - -To re-iterate, note that calling `cancel()` on the resulting promise will merely -try to cancel the input `$promise` only. -It is then up to the cancellation handler of the input promise to settle the promise. -If the input promise is still pending when the timeout occurs, then the normal -[timeout cancellation](#timeout-cancellation) handling will trigger, effectively rejecting -the output promise with a [`TimeoutException`](#timeoutexception). - -This is done for consistency with the [timeout cancellation](#timeout-cancellation) -handling and also because it is assumed this is often used like this: - -```php -$timeout = React\Promise\Timer\timeout(accessSomeRemoteResource(), 10.0); - -$timeout->cancel(); -``` - -As described above, this example works as expected and cleans up any resources -allocated for the input `$promise`. - -Note that if the given input `$promise` does not support cancellation, then this -is a NO-OP. -This means that while the resulting promise will still be rejected after the -timeout, the underlying input `$promise` may still be pending and can hence -continue consuming resources. - -#### Collections +For more details on the promise cancellation, please refer to the +[Promise documentation](https://github.com/reactphp/promise#cancellablepromiseinterface). -If you want to wait for multiple promises to resolve, you can use the normal promise primitives like this: +If you want to wait for multiple promises to resolve, you can use the normal +promise primitives like this: ```php $promises = array( @@ -270,22 +158,23 @@ $promises = array( accessSomeRemoteResource() ); -$promise = \React\Promise\all($promises); +$promise = React\Promise\all($promises); React\Promise\Timer\timeout($promise, 10)->then(function ($values) { // *all* promises resolved }); ``` -The applies to all promise collection primitives alike, i.e. `all()`, `race()`, `any()`, `some()` etc. +The applies to all promise collection primitives alike, i.e. `all()`, +`race()`, `any()`, `some()` etc. For more details on the promise primitives, please refer to the [Promise documentation](https://github.com/reactphp/promise#functions). ### resolve() -The `resolve($time, LoopInterface $loop = null)` function can be used to create a new Promise that -resolves in `$time` seconds with the `$time` as the fulfillment value. +The `resolve(float $time, ?LoopInterface $loop = null): PromiseInterface` function can be used to +create a new promise that resolves in `$time` seconds with the `$time` as the fulfillment value. ```php React\Promise\Timer\resolve(1.5)->then(function ($time) { @@ -294,9 +183,9 @@ React\Promise\Timer\resolve(1.5)->then(function ($time) { ``` Internally, the given `$time` value will be used to start a timer that will -resolve the promise once it triggers. -This implies that if you pass a really small (or negative) value, it will still -start a timer and will thus trigger at the earliest possible time in the future. +resolve the promise once it triggers. This implies that if you pass a really +small (or negative) value, it will still start a timer and will thus trigger +at the earliest possible time in the future. This function takes an optional `LoopInterface|null $loop` parameter that can be used to pass the event loop instance to use. You can use a `null` value here in order to @@ -304,9 +193,9 @@ use the [default loop](https://github.com/reactphp/event-loop#loop). This value SHOULD NOT be given unless you're sure you want to explicitly use a given event loop instance. -#### Resolve cancellation - -You can explicitly `cancel()` the resulting timer promise at any time: +The returned promise is implemented in such a way that it can be cancelled +when it is still pending. Cancelling a pending promise will reject its value +with a `RuntimeException` and clean up any pending timers. ```php $timer = React\Promise\Timer\resolve(2.0); @@ -314,12 +203,10 @@ $timer = React\Promise\Timer\resolve(2.0); $timer->cancel(); ``` -This will abort the timer and *reject* with a `RuntimeException`. - ### reject() -The `reject($time, LoopInterface $loop = null)` function can be used to create a new Promise -which rejects in `$time` seconds with a `TimeoutException`. +The `reject(float $time, ?LoopInterface $loop = null): PromiseInterface` function can be used to +create a new promise which rejects in `$time` seconds with a `TimeoutException`. ```php React\Promise\Timer\reject(2.0)->then(null, function (React\Promise\Timer\TimeoutException $e) { @@ -328,9 +215,9 @@ React\Promise\Timer\reject(2.0)->then(null, function (React\Promise\Timer\Timeou ``` Internally, the given `$time` value will be used to start a timer that will -reject the promise once it triggers. -This implies that if you pass a really small (or negative) value, it will still -start a timer and will thus trigger at the earliest possible time in the future. +reject the promise once it triggers. This implies that if you pass a really +small (or negative) value, it will still start a timer and will thus trigger +at the earliest possible time in the future. This function takes an optional `LoopInterface|null $loop` parameter that can be used to pass the event loop instance to use. You can use a `null` value here in order to @@ -338,12 +225,9 @@ use the [default loop](https://github.com/reactphp/event-loop#loop). This value SHOULD NOT be given unless you're sure you want to explicitly use a given event loop instance. -This function complements the [`resolve()`](#resolve) function -and can be used as a basic building block for higher-level promise consumers. - -#### Reject cancellation - -You can explicitly `cancel()` the resulting timer promise at any time: +The returned promise is implemented in such a way that it can be cancelled +when it is still pending. Cancelling a pending promise will reject its value +with a `RuntimeException` and clean up any pending timers. ```php $timer = React\Promise\Timer\reject(2.0); @@ -351,13 +235,15 @@ $timer = React\Promise\Timer\reject(2.0); $timer->cancel(); ``` -This will abort the timer and *reject* with a `RuntimeException`. - ### TimeoutException The `TimeoutException` extends PHP's built-in `RuntimeException`. -The `getTimeout()` method can be used to get the timeout value in seconds. + +#### getTimeout() + +The `getTimeout(): float` method can be used to +get the timeout value in seconds. ## Install diff --git a/src/TimeoutException.php b/src/TimeoutException.php index 18ea72f..09c439c 100644 --- a/src/TimeoutException.php +++ b/src/TimeoutException.php @@ -6,8 +6,15 @@ class TimeoutException extends RuntimeException { + /** @var float */ private $timeout; + /** + * @param float $timeout + * @param ?string $message + * @param ?int $code + * @param null|\Exception|\Throwable $previous + */ public function __construct($timeout, $message = null, $code = null, $previous = null) { parent::__construct($message, $code, $previous); @@ -15,6 +22,11 @@ public function __construct($timeout, $message = null, $code = null, $previous = $this->timeout = $timeout; } + /** + * Get the timeout value in seconds. + * + * @return float + */ public function getTimeout() { return $this->timeout; diff --git a/src/functions.php b/src/functions.php index c82a144..4bbd03a 100644 --- a/src/functions.php +++ b/src/functions.php @@ -8,6 +8,137 @@ use React\Promise\Promise; use React\Promise\PromiseInterface; +/** + * Cancel operations that take *too long*. + * + * You need to pass in an input `$promise` that represents a pending operation + * and timeout parameters. It returns a new promise with the following + * resolution behavior: + * + * - If the input `$promise` resolves before `$time` seconds, resolve the + * resulting promise with its fulfillment value. + * + * - If the input `$promise` rejects before `$time` seconds, reject the + * resulting promise with its rejection value. + * + * - If the input `$promise` does not settle before `$time` seconds, *cancel* + * the operation and reject the resulting promise with a [`TimeoutException`](#timeoutexception). + * + * Internally, the given `$time` value will be used to start a timer that will + * *cancel* the pending operation once it triggers. This implies that if you + * pass a really small (or negative) value, it will still start a timer and will + * thus trigger at the earliest possible time in the future. + * + * If the input `$promise` is already settled, then the resulting promise will + * resolve or reject immediately without starting a timer at all. + * + * This function takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use. You can use a `null` value here in order to + * use the [default loop](https://github.com/reactphp/event-loop#loop). This value + * SHOULD NOT be given unless you're sure you want to explicitly use a given event + * loop instance. + * + * A common use case for handling only resolved values looks like this: + * + * ```php + * $promise = accessSomeRemoteResource(); + * React\Promise\Timer\timeout($promise, 10.0)->then(function ($value) { + * // the operation finished within 10.0 seconds + * }); + * ``` + * + * A more complete example could look like this: + * + * ```php + * $promise = accessSomeRemoteResource(); + * React\Promise\Timer\timeout($promise, 10.0)->then( + * function ($value) { + * // the operation finished within 10.0 seconds + * }, + * function ($error) { + * if ($error instanceof React\Promise\Timer\TimeoutException) { + * // the operation has failed due to a timeout + * } else { + * // the input operation has failed due to some other error + * } + * } + * ); + * ``` + * + * Or if you're using [react/promise v2.2.0](https://github.com/reactphp/promise) or up: + * + * ```php + * React\Promise\Timer\timeout($promise, 10.0) + * ->then(function ($value) { + * // the operation finished within 10.0 seconds + * }) + * ->otherwise(function (React\Promise\Timer\TimeoutException $error) { + * // the operation has failed due to a timeout + * }) + * ->otherwise(function ($error) { + * // the input operation has failed due to some other error + * }) + * ; + * ``` + * + * As discussed above, the [`timeout()`](#timeout) function will take care of + * the underlying operation if it takes *too long*. In this case, you can be + * sure the resulting promise will always be rejected with a + * [`TimeoutException`](#timeoutexception). On top of this, the function will + * try to *cancel* the underlying operation. Responsibility for this + * cancellation logic is left up to the underlying operation. + * + * - A common use case involves cleaning up any resources like open network + * sockets or file handles or terminating external processes or timers. + * + * - If the given input `$promise` does not support cancellation, then this is a + * NO-OP. This means that while the resulting promise will still be rejected, + * the underlying input `$promise` may still be pending and can hence continue + * consuming resources + * + * On top of this, the returned promise is implemented in such a way that it can + * be cancelled when it is still pending. Cancelling a pending promise will + * cancel the underlying operation. As discussed above, responsibility for this + * cancellation logic is left up to the underlying operation. + * + * ```php + * $promise = accessSomeRemoteResource(); + * $timeout = React\Promise\Timer\timeout($promise, 10.0); + * + * $timeout->cancel(); + * ``` + * + * For more details on the promise cancellation, please refer to the + * [Promise documentation](https://github.com/reactphp/promise#cancellablepromiseinterface). + * + * If you want to wait for multiple promises to resolve, you can use the normal + * promise primitives like this: + * + * ```php + * $promises = array( + * accessSomeRemoteResource(), + * accessSomeRemoteResource(), + * accessSomeRemoteResource() + * ); + * + * $promise = React\Promise\all($promises); + * + * React\Promise\Timer\timeout($promise, 10)->then(function ($values) { + * // *all* promises resolved + * }); + * ``` + * + * The applies to all promise collection primitives alike, i.e. `all()`, + * `race()`, `any()`, `some()` etc. + * + * For more details on the promise primitives, please refer to the + * [Promise documentation](https://github.com/reactphp/promise#functions). + * + * @param PromiseInterface $promise + * @param float $time + * @param ?LoopInterface $loop + * @return PromiseInterface + */ function timeout(PromiseInterface $promise, $time, LoopInterface $loop = null) { // cancelling this promise will only try to cancel the input promise, @@ -61,12 +192,47 @@ function timeout(PromiseInterface $promise, $time, LoopInterface $loop = null) }, $canceller); } +/** + * Create a new promise that resolves in `$time` seconds with the `$time` as the fulfillment value. + * + * ```php + * React\Promise\Timer\resolve(1.5)->then(function ($time) { + * echo 'Thanks for waiting ' . $time . ' seconds' . PHP_EOL; + * }); + * ``` + * + * Internally, the given `$time` value will be used to start a timer that will + * resolve the promise once it triggers. This implies that if you pass a really + * small (or negative) value, it will still start a timer and will thus trigger + * at the earliest possible time in the future. + * + * This function takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use. You can use a `null` value here in order to + * use the [default loop](https://github.com/reactphp/event-loop#loop). This value + * SHOULD NOT be given unless you're sure you want to explicitly use a given event + * loop instance. + * + * The returned promise is implemented in such a way that it can be cancelled + * when it is still pending. Cancelling a pending promise will reject its value + * with a `RuntimeException` and clean up any pending timers. + * + * ```php + * $timer = React\Promise\Timer\resolve(2.0); + * + * $timer->cancel(); + * ``` + * + * @param float $time + * @param ?LoopInterface $loop + * @return PromiseInterface + */ function resolve($time, LoopInterface $loop = null) { if ($loop === null) { $loop = Loop::get(); } + $timer = null; return new Promise(function ($resolve) use ($loop, $time, &$timer) { // resolve the promise when the timer fires in $time seconds $timer = $loop->addTimer($time, function () use ($time, $resolve) { @@ -82,6 +248,40 @@ function resolve($time, LoopInterface $loop = null) }); } +/** + * Create a new promise which rejects in `$time` seconds with a `TimeoutException`. + * + * ```php + * React\Promise\Timer\reject(2.0)->then(null, function (React\Promise\Timer\TimeoutException $e) { + * echo 'Rejected after ' . $e->getTimeout() . ' seconds ' . PHP_EOL; + * }); + * ``` + * + * Internally, the given `$time` value will be used to start a timer that will + * reject the promise once it triggers. This implies that if you pass a really + * small (or negative) value, it will still start a timer and will thus trigger + * at the earliest possible time in the future. + * + * This function takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use. You can use a `null` value here in order to + * use the [default loop](https://github.com/reactphp/event-loop#loop). This value + * SHOULD NOT be given unless you're sure you want to explicitly use a given event + * loop instance. + * + * The returned promise is implemented in such a way that it can be cancelled + * when it is still pending. Cancelling a pending promise will reject its value + * with a `RuntimeException` and clean up any pending timers. + * + * ```php + * $timer = React\Promise\Timer\reject(2.0); + * + * $timer->cancel(); + * ``` + * + * @param float $time + * @param LoopInterface $loop + * @return PromiseInterface + */ function reject($time, LoopInterface $loop = null) { return resolve($time, $loop)->then(function ($time) {