Skip to content

Commit b7555d8

Browse files
committed
Trigger an E_USER_ERROR instead of throwing an exception from done()
If an error (either a thrown exception or returned rejection) escapes the done() callbacks, it will now cause a fatal error by using trigger_error() with E_USER_ERROR instead of (re)throwing. Because promise resolution is synchronous, those exceptions bubbled up to the reject() call which mixed resolution and consumption parts.
1 parent 3675021 commit b7555d8

9 files changed

+248
-72
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ $promise->done(callable $onFulfilled = null, callable $onRejected = null);
203203
Consumes the promise's ultimate value if the promise fulfills, or handles the
204204
ultimate error.
205205

206-
It will cause a fatal error if either `$onFulfilled` or `$onRejected` throw or
207-
return a rejected promise.
206+
It will cause a fatal error (`E_USER_ERROR`) if either `$onFulfilled` or
207+
`$onRejected` throw or return a rejected promise.
208208

209209
Since the purpose of `done()` is consumption rather than transformation,
210210
`done()` always returns `null`.
@@ -674,8 +674,8 @@ by the promise machinery and used to reject the promise returned by `then()`.
674674

675675
Calling `done()` transfers all responsibility for errors to your code. If an
676676
error (either a thrown exception or returned rejection) escapes the
677-
`$onFulfilled` or `$onRejected` callbacks you provide to done, it will be
678-
rethrown in an uncatchable way causing a fatal error.
677+
`$onFulfilled` or `$onRejected` callbacks you provide to `done()`, it will cause
678+
a fatal error.
679679

680680
```php
681681
function getJsonResult()

src/FulfilledPromise.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ public function done(callable $onFulfilled = null, callable $onRejected = null)
4141
}
4242

4343
enqueue(function () use ($onFulfilled) {
44-
$result = $onFulfilled($this->value);
44+
try {
45+
$result = $onFulfilled($this->value);
46+
} catch (\Throwable $exception) {
47+
return fatalError($exception);
48+
} catch (\Exception $exception) {
49+
return fatalError($exception);
50+
}
4551

4652
if ($result instanceof PromiseInterface) {
4753
$result->done();

src/RejectedPromise.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,23 @@ public function done(callable $onFulfilled = null, callable $onRejected = null)
3838
{
3939
enqueue(function () use ($onRejected) {
4040
if (null === $onRejected) {
41-
throw UnhandledRejectionException::resolve($this->reason);
41+
return fatalError(
42+
UnhandledRejectionException::resolve($this->reason)
43+
);
4244
}
4345

44-
$result = $onRejected($this->reason);
46+
try {
47+
$result = $onRejected($this->reason);
48+
} catch (\Throwable $exception) {
49+
return fatalError($exception);
50+
} catch (\Exception $exception) {
51+
return fatalError($exception);
52+
}
4553

4654
if ($result instanceof self) {
47-
throw UnhandledRejectionException::resolve($result->reason);
55+
return fatalError(
56+
UnhandledRejectionException::resolve($result->reason)
57+
);
4858
}
4959

5060
if ($result instanceof PromiseInterface) {

src/functions.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,22 @@ function enqueue(callable $task)
203203
$queue->enqueue($task);
204204
}
205205

206+
/**
207+
* @internal
208+
*/
209+
function fatalError($error)
210+
{
211+
try {
212+
trigger_error($error, E_USER_ERROR);
213+
} catch (\Throwable $e) {
214+
set_error_handler(null);
215+
trigger_error($error, E_USER_ERROR);
216+
} catch (\Exception $e) {
217+
set_error_handler(null);
218+
trigger_error($error, E_USER_ERROR);
219+
}
220+
}
221+
206222
/**
207223
* @internal
208224
*/

tests/ErrorCollector.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace React\Promise;
4+
5+
final class ErrorCollector
6+
{
7+
private $errors = [];
8+
9+
public function start()
10+
{
11+
$errors = [];
12+
13+
set_error_handler(function ($errno, $errstr, $errfile, $errline, $errcontext) use (&$errors) {
14+
$errors[] = compact('errno', 'errstr', 'errfile', 'errline', 'errcontext');
15+
});
16+
17+
$this->errors = &$errors;
18+
}
19+
20+
public function stop()
21+
{
22+
$errors = $this->errors;
23+
$this->errors = [];
24+
25+
restore_error_handler();
26+
27+
return $errors;
28+
}
29+
}

tests/PromiseTest/PromiseFulfilledTestTrait.php

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace React\Promise\PromiseTest;
44

5+
use React\Promise\ErrorCollector;
6+
57
trait PromiseFulfilledTestTrait
68
{
79
/**
@@ -212,29 +214,43 @@ public function doneShouldInvokeFulfillmentHandlerForFulfilledPromise()
212214
}
213215

214216
/** @test */
215-
public function doneShouldThrowExceptionThrownFulfillmentHandlerForFulfilledPromise()
217+
public function doneShouldTriggerFatalErrorThrownFulfillmentHandlerForFulfilledPromise()
216218
{
217219
$adapter = $this->getPromiseTestAdapter();
218220

219-
$this->setExpectedException('\Exception', 'UnhandledRejectionException');
220-
221221
$adapter->resolve(1);
222+
223+
$errorCollector = new ErrorCollector();
224+
$errorCollector->start();
225+
222226
$this->assertNull($adapter->promise()->done(function () {
223-
throw new \Exception('UnhandledRejectionException');
227+
throw new \Exception('Unhandled Rejection');
224228
}));
229+
230+
$errors = $errorCollector->stop();
231+
232+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
233+
$this->assertContains('Unhandled Rejection', $errors[0]['errstr']);
225234
}
226235

227236
/** @test */
228-
public function doneShouldThrowUnhandledRejectionExceptionWhenFulfillmentHandlerRejectsForFulfilledPromise()
237+
public function doneShouldTriggerFatalErrorUnhandledRejectionExceptionWhenFulfillmentHandlerRejectsForFulfilledPromise()
229238
{
230239
$adapter = $this->getPromiseTestAdapter();
231240

232-
$this->setExpectedException('React\\Promise\\UnhandledRejectionException');
233-
234241
$adapter->resolve(1);
242+
243+
$errorCollector = new ErrorCollector();
244+
$errorCollector->start();
245+
235246
$this->assertNull($adapter->promise()->done(function () {
236247
return \React\Promise\reject();
237248
}));
249+
250+
$errors = $errorCollector->stop();
251+
252+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
253+
$this->assertContains('Unhandled Rejection: null', $errors[0]['errstr']);
238254
}
239255

240256
/** @test */

tests/PromiseTest/PromiseRejectedTestTrait.php

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace React\Promise\PromiseTest;
44

55
use React\Promise\Deferred;
6+
use React\Promise\ErrorCollector;
67
use React\Promise\UnhandledRejectionException;
78

89
trait PromiseRejectedTestTrait
@@ -200,27 +201,40 @@ public function doneShouldInvokeRejectionHandlerForRejectedPromise()
200201
}
201202

202203
/** @test */
203-
public function doneShouldThrowExceptionThrownByRejectionHandlerForRejectedPromise()
204+
public function doneShouldTriggerFatalErrorExceptionThrownByRejectionHandlerForRejectedPromise()
204205
{
205-
$adapter = $this->getPromiseTestAdapter();
206+
$errorCollector = new ErrorCollector();
207+
$errorCollector->start();
206208

207-
$this->setExpectedException('\Exception', 'UnhandledRejectionException');
209+
$adapter = $this->getPromiseTestAdapter();
208210

209211
$adapter->reject(1);
210212
$this->assertNull($adapter->promise()->done(null, function () {
211-
throw new \Exception('UnhandledRejectionException');
213+
throw new \Exception('Unhandled Rejection');
212214
}));
215+
216+
$errors = $errorCollector->stop();
217+
218+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
219+
$this->assertContains('Unhandled Rejection', $errors[0]['errstr']);
213220
}
214221

215222
/** @test */
216-
public function doneShouldThrowUnhandledRejectionExceptionWhenRejectedWithNonExceptionForRejectedPromise()
223+
public function doneShouldTriggerFatalErrorUnhandledRejectionExceptionWhenRejectedWithNonExceptionForRejectedPromise()
217224
{
218225
$adapter = $this->getPromiseTestAdapter();
219226

220-
$this->setExpectedException('React\\Promise\\UnhandledRejectionException');
221-
222227
$adapter->reject(1);
228+
229+
$errorCollector = new ErrorCollector();
230+
$errorCollector->start();
231+
223232
$this->assertNull($adapter->promise()->done());
233+
234+
$errors = $errorCollector->stop();
235+
236+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
237+
$this->assertContains('Unhandled Rejection: 1', $errors[0]['errstr']);
224238
}
225239

226240
/** @test */
@@ -232,57 +246,83 @@ public function unhandledRejectionExceptionThrownByDoneHoldsRejectionValue()
232246

233247
$adapter->reject($expected);
234248

235-
try {
236-
$adapter->promise()->done();
237-
} catch (UnhandledRejectionException $e) {
238-
$this->assertSame($expected, $e->getReason());
239-
return;
240-
}
249+
$errorCollector = new ErrorCollector();
250+
$errorCollector->start();
251+
252+
$adapter->promise()->done();
253+
254+
$errors = $errorCollector->stop();
241255

242-
$this->fail();
256+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
257+
$this->assertContains('Unhandled Rejection: {}', $errors[0]['errstr']);
258+
259+
$this->assertArrayHasKey('error', $errors[0]['errcontext']);
260+
$this->assertInstanceOf('React\Promise\UnhandledRejectionException', $errors[0]['errcontext']['error']);
261+
$this->assertSame($expected, $errors[0]['errcontext']['error']->getReason());
243262
}
244263

245264
/** @test */
246-
public function doneShouldThrowUnhandledRejectionExceptionWhenRejectionHandlerRejectsForRejectedPromise()
265+
public function doneShouldTriggerFatalErrorUnhandledRejectionExceptionWhenRejectionHandlerRejectsForRejectedPromise()
247266
{
248-
$adapter = $this->getPromiseTestAdapter();
267+
$errorCollector = new ErrorCollector();
268+
$errorCollector->start();
249269

250-
$this->setExpectedException('React\\Promise\\UnhandledRejectionException');
270+
$adapter = $this->getPromiseTestAdapter();
251271

252272
$adapter->reject(1);
253273
$this->assertNull($adapter->promise()->done(null, function () {
254274
return \React\Promise\reject();
255275
}));
276+
277+
$errors = $errorCollector->stop();
278+
279+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
280+
$this->assertContains('Unhandled Rejection: null', $errors[0]['errstr']);
256281
}
257282

258283
/** @test */
259-
public function doneShouldThrowRejectionExceptionWhenRejectionHandlerRejectsWithExceptionForRejectedPromise()
284+
public function doneShouldTriggerFatalErrorRejectionExceptionWhenRejectionHandlerRejectsWithExceptionForRejectedPromise()
260285
{
261-
$adapter = $this->getPromiseTestAdapter();
286+
$errorCollector = new ErrorCollector();
287+
$errorCollector->start();
262288

263-
$this->setExpectedException('\Exception', 'UnhandledRejectionException');
289+
$adapter = $this->getPromiseTestAdapter();
264290

265291
$adapter->reject(1);
266292
$this->assertNull($adapter->promise()->done(null, function () {
267-
return \React\Promise\reject(new \Exception('UnhandledRejectionException'));
293+
return \React\Promise\reject(new \Exception('Unhandled Rejection'));
268294
}));
295+
296+
$errors = $errorCollector->stop();
297+
298+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
299+
$this->assertContains('Unhandled Rejection', $errors[0]['errstr']);
269300
}
270301

271302
/** @test */
272-
public function doneShouldThrowExceptionProvidedAsRejectionValueForRejectedPromise()
303+
public function doneShouldTriggerFatalErrorExceptionProvidedAsRejectionValueForRejectedPromise()
273304
{
305+
$errorCollector = new ErrorCollector();
306+
$errorCollector->start();
307+
274308
$adapter = $this->getPromiseTestAdapter();
275309

276-
$this->setExpectedException('\Exception', 'UnhandledRejectionException');
310+
$exception = new \Exception('Unhandled Rejection');
277311

278-
$adapter->reject(new \Exception('UnhandledRejectionException'));
312+
$adapter->reject($exception);
279313
$this->assertNull($adapter->promise()->done());
314+
315+
$errors = $errorCollector->stop();
316+
317+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
318+
$this->assertEquals((string) $exception, $errors[0]['errstr']);
280319
}
281320

282321
/** @test */
283-
public function doneShouldThrowWithDeepNestingPromiseChainsForRejectedPromise()
322+
public function doneShouldTriggerFatalErrorWithDeepNestingPromiseChainsForRejectedPromise()
284323
{
285-
$this->setExpectedException('\Exception', 'UnhandledRejectionException');
324+
$errorCollector = new ErrorCollector();
325+
$errorCollector->start();
286326

287327
$exception = new \Exception('UnhandledRejectionException');
288328

@@ -301,6 +341,11 @@ function () use ($exception) {
301341
})));
302342

303343
$result->done();
344+
345+
$errors = $errorCollector->stop();
346+
347+
$this->assertEquals(E_USER_ERROR, $errors[0]['errno']);
348+
$this->assertEquals((string) $exception, $errors[0]['errstr']);
304349
}
305350

306351
/** @test */

0 commit comments

Comments
 (0)