Skip to content

Commit 4a9401a

Browse files
authored
Merge pull request #117 from clue-labs/fibers-automatic
Automatically start new fiber for each request on PHP 8.1+
2 parents 289b086 + f61d0e3 commit 4a9401a

File tree

8 files changed

+406
-6
lines changed

8 files changed

+406
-6
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"react/promise": "^2.7"
1818
},
1919
"require-dev": {
20-
"phpunit/phpunit": "^9.5 || ^7.5"
20+
"phpunit/phpunit": "^9.5 || ^7.5",
21+
"react/async": "^4@dev || ^3@dev"
2122
},
2223
"autoload": {
2324
"psr-4": {

examples/index.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@
2424
);
2525
});
2626

27+
$app->get('/sleep/promise', function () {
28+
return React\Promise\Timer\sleep(0.1)->then(function () {
29+
return React\Http\Message\Response::plaintext("OK\n");
30+
});
31+
});
32+
$app->get('/sleep/coroutine', function () {
33+
yield React\Promise\Timer\sleep(0.1);
34+
return React\Http\Message\Response::plaintext("OK\n");
35+
});
36+
if (PHP_VERSION_ID >= 80100 && function_exists('React\Async\async')) { // requires PHP 8.1+ with react/async 4+
37+
$app->get('/sleep/fiber', function () {
38+
React\Async\await(React\Promise\Timer\sleep(0.1));
39+
return React\Http\Message\Response::plaintext("OK\n");
40+
});
41+
}
42+
2743
$app->get('/uri[/{path:.*}]', function (ServerRequestInterface $request) {
2844
return React\Http\Message\Response::plaintext(
2945
(string) $request->getUri() . "\n"

src/App.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,19 @@ public function __construct(...$middleware)
5252
}
5353
}
5454

55-
// new MiddlewareHandler([$accessLogHandler, $errorHandler, ...$middleware, $routeHandler])
55+
// new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler])
5656
\array_unshift($middleware, $errorHandler);
5757

5858
// only log for built-in webserver and PHP development webserver by default, others have their own access log
5959
if (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') {
6060
\array_unshift($middleware, new AccessLogHandler());
6161
}
6262

63+
// automatically start new fiber for each request on PHP 8.1+
64+
if (\PHP_VERSION_ID >= 80100) {
65+
\array_unshift($middleware, new FiberHandler()); // @codeCoverageIgnore
66+
}
67+
6368
$this->router = new RouteHandler($container);
6469
$middleware[] = $this->router;
6570
$this->handler = new MiddlewareHandler($middleware);

src/FiberHandler.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace FrameworkX;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use Psr\Http\Message\ServerRequestInterface;
7+
use React\Promise\Deferred;
8+
use React\Promise\PromiseInterface;
9+
10+
/**
11+
* [Internal] Fibers middleware handler to ensure each request is processed in a separate `Fiber`
12+
*
13+
* The `Fiber` class has been added in PHP 8.1+, so this middleware is only used
14+
* on PHP 8.1+. On supported PHP versions, this middleware is automatically
15+
* added to the list of middleware handlers, so there's no need to reference
16+
* this class in application code.
17+
*
18+
* @internal
19+
* @link https://framework-x.org/docs/async/fibers/
20+
*/
21+
class FiberHandler
22+
{
23+
/**
24+
* @return ResponseInterface|PromiseInterface<ResponseInterface,void>|\Generator
25+
* Returns a `ResponseInterface` from the next request handler in the
26+
* chain. If the next request handler returns immediately, this method
27+
* will return immediately. If the next request handler suspends the
28+
* fiber (see `await()`), this method will return a `PromiseInterface`
29+
* that is fulfilled with a `ResponseInterface` when the fiber is
30+
* terminated successfully. If the next request handler returns a
31+
* promise, this method will return a promise that follows its
32+
* resolution. If the next request handler returns a Generator-based
33+
* coroutine, this method returns a `Generator`. This method never
34+
* throws or resolves a rejected promise. If the handler fails, it will
35+
* be turned into a valid error response before returning.
36+
* @throws void
37+
*/
38+
public function __invoke(ServerRequestInterface $request, callable $next): mixed
39+
{
40+
$deferred = null;
41+
$fiber = new \Fiber(function () use ($request, $next, &$deferred) {
42+
$response = $next($request);
43+
assert($response instanceof ResponseInterface || $response instanceof PromiseInterface || $response instanceof \Generator);
44+
45+
if ($deferred !== null) {
46+
$deferred->resolve($response);
47+
}
48+
49+
return $response;
50+
});
51+
52+
$fiber->start();
53+
if ($fiber->isTerminated()) {
54+
return $fiber->getReturn();
55+
}
56+
57+
$deferred = new Deferred();
58+
return $deferred->promise();
59+
}
60+
}

tests/AppMiddlewareTest.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace FrameworkX\Tests;
44

5+
use FrameworkX\AccessLogHandler;
56
use FrameworkX\App;
7+
use FrameworkX\FiberHandler;
68
use FrameworkX\RouteHandler;
79
use PHPUnit\Framework\TestCase;
810
use Psr\Http\Message\ResponseInterface;
@@ -788,8 +790,20 @@ private function createAppWithoutLogger(...$middleware): App
788790
$ref->setAccessible(true);
789791
$handlers = $ref->getValue($middleware);
790792

791-
unset($handlers[0]);
792-
$ref->setValue($middleware, array_values($handlers));
793+
if (PHP_VERSION_ID >= 80100) {
794+
$first = array_shift($handlers);
795+
$this->assertInstanceOf(FiberHandler::class, $first);
796+
797+
$next = array_shift($handlers);
798+
$this->assertInstanceOf(AccessLogHandler::class, $next);
799+
800+
array_unshift($handlers, $next, $first);
801+
}
802+
803+
$first = array_shift($handlers);
804+
$this->assertInstanceOf(AccessLogHandler::class, $first);
805+
806+
$ref->setValue($middleware, $handlers);
793807

794808
return $app;
795809
}

tests/AppTest.php

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use FrameworkX\App;
77
use FrameworkX\Container;
88
use FrameworkX\ErrorHandler;
9+
use FrameworkX\FiberHandler;
910
use FrameworkX\MiddlewareHandler;
1011
use FrameworkX\RouteHandler;
1112
use FrameworkX\SapiHandler;
@@ -26,10 +27,12 @@
2627
use React\EventLoop\Loop;
2728
use React\Http\Message\Response;
2829
use React\Http\Message\ServerRequest;
30+
use React\Promise\Deferred;
2931
use React\Promise\Promise;
3032
use React\Promise\PromiseInterface;
3133
use ReflectionMethod;
3234
use ReflectionProperty;
35+
use function React\Async\await;
3336
use function React\Promise\reject;
3437
use function React\Promise\resolve;
3538

@@ -72,6 +75,11 @@ public function testConstructWithMiddlewareAssignsGivenMiddleware()
7275
$ref->setAccessible(true);
7376
$handlers = $ref->getValue($handler);
7477

78+
if (PHP_VERSION_ID >= 80100) {
79+
$first = array_shift($handlers);
80+
$this->assertInstanceOf(FiberHandler::class, $first);
81+
}
82+
7583
$this->assertCount(4, $handlers);
7684
$this->assertInstanceOf(AccessLogHandler::class, $handlers[0]);
7785
$this->assertInstanceOf(ErrorHandler::class, $handlers[1]);
@@ -93,6 +101,11 @@ public function testConstructWithContainerAssignsContainerForRouteHandlerOnly()
93101
$ref->setAccessible(true);
94102
$handlers = $ref->getValue($handler);
95103

104+
if (PHP_VERSION_ID >= 80100) {
105+
$first = array_shift($handlers);
106+
$this->assertInstanceOf(FiberHandler::class, $first);
107+
}
108+
96109
$this->assertCount(3, $handlers);
97110
$this->assertInstanceOf(AccessLogHandler::class, $handlers[0]);
98111
$this->assertInstanceOf(ErrorHandler::class, $handlers[1]);
@@ -122,6 +135,11 @@ public function testConstructWithContainerAndMiddlewareClassNameAssignsCallableF
122135
$ref->setAccessible(true);
123136
$handlers = $ref->getValue($handler);
124137

138+
if (PHP_VERSION_ID >= 80100) {
139+
$first = array_shift($handlers);
140+
$this->assertInstanceOf(FiberHandler::class, $first);
141+
}
142+
125143
$this->assertCount(4, $handlers);
126144
$this->assertInstanceOf(AccessLogHandler::class, $handlers[0]);
127145
$this->assertInstanceOf(ErrorHandler::class, $handlers[1]);
@@ -820,6 +838,57 @@ public function testHandleRequestWithMatchingRouteReturnsPendingPromiseWhenHandl
820838
$this->assertFalse($resolved);
821839
}
822840

841+
public function testHandleRequestWithMatchingRouteReturnsPromiseResolvingWithResponseWhenHandlerReturnsResponseAfterAwaitingPromiseResolvingWithResponse()
842+
{
843+
if (PHP_VERSION_ID < 80100 || !function_exists('React\Async\async')) {
844+
$this->markTestSkipped('Requires PHP 8.1+ with react/async 4+');
845+
}
846+
847+
$app = $this->createAppWithoutLogger();
848+
849+
$deferred = new Deferred();
850+
851+
$app->get('/users', function () use ($deferred) {
852+
return await($deferred->promise());
853+
});
854+
855+
$request = new ServerRequest('GET', 'http://localhost/users');
856+
857+
// $promise = $app->handleRequest($request);
858+
$ref = new ReflectionMethod($app, 'handleRequest');
859+
$ref->setAccessible(true);
860+
$promise = $ref->invoke($app, $request);
861+
862+
/** @var PromiseInterface $promise */
863+
$this->assertInstanceOf(PromiseInterface::class, $promise);
864+
865+
$response = null;
866+
$promise->then(function ($value) use (&$response) {
867+
$response = $value;
868+
});
869+
870+
$this->assertNull($response);
871+
872+
$deferred->resolve(new Response(
873+
200,
874+
[
875+
'Content-Type' => 'text/html'
876+
],
877+
"OK\n"
878+
));
879+
880+
// await next tick: https://github.com/reactphp/async/issues/27
881+
await(new Promise(function ($resolve) {
882+
Loop::futureTick($resolve);
883+
}));
884+
885+
/** @var ResponseInterface $response */
886+
$this->assertInstanceOf(ResponseInterface::class, $response);
887+
$this->assertEquals(200, $response->getStatusCode());
888+
$this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
889+
$this->assertEquals("OK\n", (string) $response->getBody());
890+
}
891+
823892
public function testHandleRequestWithMatchingRouteAndRouteVariablesReturnsResponseFromHandlerWithRouteVariablesAssignedAsRequestAttributes()
824893
{
825894
$app = $this->createAppWithoutLogger();
@@ -1047,6 +1116,58 @@ public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWit
10471116
$this->assertStringContainsString("<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> with message <code>Foo</code> in <code title=\"See " . __FILE__ . " line $line\">AppTest.php:$line</code>.</p>\n", (string) $response->getBody());
10481117
}
10491118

1119+
public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerThrowsAfterAwaitingPromiseRejectingWithException()
1120+
{
1121+
if (PHP_VERSION_ID < 80100 || !function_exists('React\Async\async')) {
1122+
$this->markTestSkipped('Requires PHP 8.1+ with react/async 4+');
1123+
}
1124+
1125+
$app = $this->createAppWithoutLogger();
1126+
1127+
$deferred = new Deferred();
1128+
1129+
$line = __LINE__ + 1;
1130+
$exception = new \RuntimeException('Foo');
1131+
1132+
$app->get('/users', function () use ($deferred) {
1133+
return await($deferred->promise());
1134+
});
1135+
1136+
$request = new ServerRequest('GET', 'http://localhost/users');
1137+
1138+
// $promise = $app->handleRequest($request);
1139+
$ref = new ReflectionMethod($app, 'handleRequest');
1140+
$ref->setAccessible(true);
1141+
$promise = $ref->invoke($app, $request);
1142+
1143+
/** @var PromiseInterface $promise */
1144+
$this->assertInstanceOf(PromiseInterface::class, $promise);
1145+
1146+
$response = null;
1147+
$promise->then(function ($value) use (&$response) {
1148+
$response = $value;
1149+
});
1150+
1151+
$this->assertNull($response);
1152+
1153+
$deferred->reject($exception);
1154+
1155+
// await next tick: https://github.com/reactphp/async/issues/27
1156+
await(new Promise(function ($resolve) {
1157+
Loop::futureTick($resolve);
1158+
}));
1159+
1160+
/** @var ResponseInterface $response */
1161+
$this->assertInstanceOf(ResponseInterface::class, $response);
1162+
$this->assertEquals(500, $response->getStatusCode());
1163+
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
1164+
$this->assertStringMatchesFormat("<!DOCTYPE html>\n<html>%a</html>\n", (string) $response->getBody());
1165+
1166+
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
1167+
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
1168+
$this->assertStringContainsString("<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> with message <code>Foo</code> in <code title=\"See " . __FILE__ . " line $line\">AppTest.php:$line</code>.</p>\n", (string) $response->getBody());
1169+
}
1170+
10501171
public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichReturnsNull()
10511172
{
10521173
$app = $this->createAppWithoutLogger();
@@ -1386,8 +1507,20 @@ private function createAppWithoutLogger(): App
13861507
$ref->setAccessible(true);
13871508
$handlers = $ref->getValue($middleware);
13881509

1389-
unset($handlers[0]);
1390-
$ref->setValue($middleware, array_values($handlers));
1510+
if (PHP_VERSION_ID >= 80100) {
1511+
$first = array_shift($handlers);
1512+
$this->assertInstanceOf(FiberHandler::class, $first);
1513+
1514+
$next = array_shift($handlers);
1515+
$this->assertInstanceOf(AccessLogHandler::class, $next);
1516+
1517+
array_unshift($handlers, $next, $first);
1518+
}
1519+
1520+
$first = array_shift($handlers);
1521+
$this->assertInstanceOf(AccessLogHandler::class, $first);
1522+
1523+
$ref->setValue($middleware, $handlers);
13911524

13921525
return $app;
13931526
}

0 commit comments

Comments
 (0)