From 942e1cda070b8bdccf70a4dde36f7f1eb3e35ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 26 Mar 2023 19:41:54 +0200 Subject: [PATCH] Refactor reactive server logic into new `ReactiveHandler` --- src/App.php | 135 +++-------- src/Io/ReactiveHandler.php | 90 ++++++++ src/Io/SapiHandler.php | 29 +++ tests/AppTest.php | 378 ++----------------------------- tests/Io/ReactiveHandlerTest.php | 308 +++++++++++++++++++++++++ tests/Io/SapiHandlerTest.php | 39 ++++ 6 files changed, 514 insertions(+), 465 deletions(-) create mode 100644 src/Io/ReactiveHandler.php create mode 100644 tests/Io/ReactiveHandlerTest.php diff --git a/src/App.php b/src/App.php index 7f50a33..b7c29c9 100644 --- a/src/App.php +++ b/src/App.php @@ -3,19 +3,16 @@ namespace FrameworkX; use FrameworkX\Io\FiberHandler; -use FrameworkX\Io\LogStreamHandler; use FrameworkX\Io\MiddlewareHandler; +use FrameworkX\Io\ReactiveHandler; use FrameworkX\Io\RedirectHandler; use FrameworkX\Io\RouteHandler; use FrameworkX\Io\SapiHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Loop; -use React\Http\HttpServer; use React\Http\Message\Response; use React\Promise\Deferred; use React\Promise\PromiseInterface; -use React\Socket\SocketServer; class App { @@ -25,15 +22,9 @@ class App /** @var RouteHandler */ private $router; - /** @var ?SapiHandler */ + /** @var ReactiveHandler|SapiHandler */ private $sapi; - /** @var ?LogStreamHandler */ - private $logger; - - /** @var Container */ - private $container; - /** * Instantiate new X application * @@ -53,19 +44,19 @@ public function __construct(...$middleware) // new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) $handlers = []; - $this->container = $needsErrorHandler = new Container(); + $container = $needsErrorHandler = new Container(); // only log for built-in webserver and PHP development webserver by default, others have their own access log - $needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') ? $this->container : null; + $needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') ? $container : null; if ($middleware) { $needsErrorHandlerNext = false; foreach ($middleware as $handler) { // load AccessLogHandler and ErrorHandler instance from last Container if ($handler === AccessLogHandler::class) { - $handler = $this->container->getAccessLogHandler(); + $handler = $container->getAccessLogHandler(); } elseif ($handler === ErrorHandler::class) { - $handler = $this->container->getErrorHandler(); + $handler = $container->getErrorHandler(); } // ensure AccessLogHandler is always followed by ErrorHandler @@ -76,14 +67,14 @@ public function __construct(...$middleware) if ($handler instanceof Container) { // remember last Container to load any following class names - $this->container = $handler; + $container = $handler; // add default ErrorHandler from last Container before adding any other handlers, may be followed by other Container instances (unlikely) if (!$handlers) { - $needsErrorHandler = $needsAccessLog = $this->container; + $needsErrorHandler = $needsAccessLog = $container; } } elseif (!\is_callable($handler)) { - $handlers[] = $this->container->callable($handler); + $handlers[] = $container->callable($handler); } else { // don't need a default ErrorHandler if we're adding one as first handler or AccessLogHandler as first followed by one if ($needsErrorHandler && ($handler instanceof ErrorHandler || $handler instanceof AccessLogHandler) && !$handlers) { @@ -116,11 +107,10 @@ public function __construct(...$middleware) \array_unshift($handlers, new FiberHandler()); // @codeCoverageIgnore } - $this->router = new RouteHandler($this->container); + $this->router = new RouteHandler($container); $handlers[] = $this->router; $this->handler = new MiddlewareHandler($handlers); - $this->sapi = (\PHP_SAPI !== 'cli' ? new SapiHandler() : null); - $this->logger = (\PHP_SAPI === 'cli' ? new LogStreamHandler('php://output') : null); + $this->sapi = \PHP_SAPI === 'cli' ? new ReactiveHandler($container->getEnv('X_LISTEN')) : new SapiHandler(); } /** @@ -225,90 +215,29 @@ public function redirect(string $route, string $target, int $code = Response::ST $this->any($route, new RedirectHandler($target, $code)); } + /** + * Runs the app to handle HTTP requests according to any registered routes and middleware. + * + * This is where the magic happens: When executed on the command line (CLI), + * this will run the powerful reactive request handler built on top of + * ReactPHP. This works by running the efficient built-in HTTP web server to + * handle incoming HTTP requests through ReactPHP's HTTP and socket server. + * This async execution mode is usually recommended as it can efficiently + * process a large number of concurrent connections and process multiple + * incoming requests simultaneously. The long-running server process will + * continue to run until it is interrupted by a signal. + * + * When executed behind traditional PHP SAPIs (PHP-FPM, FastCGI, Apache, etc.), + * this will handle a single request and run until a single response is sent. + * This is particularly useful because it allows you to run the exact same + * app in any environment. + * + * @see ReactiveHandler::run() + * @see SapiHandler::run() + */ public function run(): void { - if (\PHP_SAPI === 'cli') { - $this->runLoop(); - } else { - $this->runOnce(); // @codeCoverageIgnore - } - } - - private function runLoop(): void - { - $logger = $this->logger; - assert($logger instanceof LogStreamHandler); - - $http = new HttpServer(function (ServerRequestInterface $request) { - return $this->handleRequest($request); - }); - - $listen = $this->container->getEnv('X_LISTEN') ?? '127.0.0.1:8080'; - - $socket = new SocketServer($listen); - $http->listen($socket); - - $logger->log('Listening on ' . \str_replace('tcp:', 'http:', (string) $socket->getAddress())); - - $http->on('error', static function (\Exception $e) use ($logger): void { - $logger->log('HTTP error: ' . $e->getMessage()); - }); - - // @codeCoverageIgnoreStart - try { - Loop::addSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 = static function () use ($socket, $logger) { - if (\PHP_VERSION_ID >= 70200 && \stream_isatty(\STDIN)) { - echo "\r"; - } - $logger->log('Received SIGINT, stopping loop'); - - $socket->close(); - Loop::stop(); - }); - Loop::addSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 = static function () use ($socket, $logger) { - $logger->log('Received SIGTERM, stopping loop'); - - $socket->close(); - Loop::stop(); - }); - } catch (\BadMethodCallException $e) { - $logger->log('Notice: No signal handler support, installing ext-ev or ext-pcntl recommended for production use.'); - } - // @codeCoverageIgnoreEnd - - do { - Loop::run(); - - if ($socket->getAddress() !== null) { - // Fiber compatibility mode for PHP < 8.1: Restart loop as long as socket is available - $logger->log('Warning: Loop restarted. Upgrade to react/async v4 recommended for production use.'); - } else { - break; - } - } while (true); - - // remove signal handlers when loop stops (if registered) - Loop::removeSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 ?? 'printf'); - Loop::removeSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 ?? 'printf'); - } - - private function runOnce(): void - { - assert($this->sapi instanceof SapiHandler); - $request = $this->sapi->requestFromGlobals(); - - $response = $this->handleRequest($request); - - if ($response instanceof ResponseInterface) { - $this->sapi->sendResponse($response); - } elseif ($response instanceof PromiseInterface) { - $response->then(function (ResponseInterface $response) { - assert($this->sapi instanceof SapiHandler); - $this->sapi->sendResponse($response); - }); - } - - Loop::run(); + $this->sapi->run(\Closure::fromCallable([$this, 'handleRequest'])); } /** diff --git a/src/Io/ReactiveHandler.php b/src/Io/ReactiveHandler.php new file mode 100644 index 0000000..1ebd4b7 --- /dev/null +++ b/src/Io/ReactiveHandler.php @@ -0,0 +1,90 @@ +logger = new LogStreamHandler('php://output'); + $this->listenAddress = $listenAddress ?? '127.0.0.1:8080'; + } + + public function run(callable $handler): void + { + $socket = new SocketServer($this->listenAddress); + + $http = new HttpServer($handler); + $http->listen($socket); + + $logger = $this->logger; + $logger->log('Listening on ' . \str_replace('tcp:', 'http:', (string) $socket->getAddress())); + + $http->on('error', static function (\Exception $e) use ($logger): void { + $logger->log('HTTP error: ' . $e->getMessage()); + }); + + // @codeCoverageIgnoreStart + try { + Loop::addSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 = static function () use ($socket, $logger): void { + if (\PHP_VERSION_ID >= 70200 && \stream_isatty(\STDIN)) { + echo "\r"; + } + $logger->log('Received SIGINT, stopping loop'); + + $socket->close(); + Loop::stop(); + }); + Loop::addSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 = static function () use ($socket, $logger): void { + $logger->log('Received SIGTERM, stopping loop'); + + $socket->close(); + Loop::stop(); + }); + } catch (\BadMethodCallException $e) { + $logger->log('Notice: No signal handler support, installing ext-ev or ext-pcntl recommended for production use.'); + } + // @codeCoverageIgnoreEnd + + do { + Loop::run(); + + if ($socket->getAddress() !== null) { + // Fiber compatibility mode for PHP < 8.1: Restart loop as long as socket is available + $logger->log('Warning: Loop restarted. Upgrade to react/async v4 recommended for production use.'); + } else { + break; + } + } while (true); + + // remove signal handlers when loop stops (if registered) + Loop::removeSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 ?? 'printf'); + Loop::removeSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 ?? 'printf'); + } +} diff --git a/src/Io/SapiHandler.php b/src/Io/SapiHandler.php index 7170f89..918a62f 100644 --- a/src/Io/SapiHandler.php +++ b/src/Io/SapiHandler.php @@ -4,15 +4,44 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use React\EventLoop\Loop; use React\Http\Message\Response; use React\Http\Message\ServerRequest; +use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; /** + * [Internal] Request handler for traditional PHP SAPIs. + * + * This request handler will be used when executed behind traditional PHP SAPIs + * (PHP-FPM, FastCGI, Apache, etc.). It will handle a single request and run + * until a single response is sent. This is particularly useful because it + * allows you to run the exact same app in any environment. + * + * Note that this is an internal class only and nothing you should usually have + * to care about. See also the `App` and `ReactiveHandler` for more details. + * * @internal */ class SapiHandler { + public function run(callable $handler): void + { + $request = $this->requestFromGlobals(); + + $response = $handler($request); + + if ($response instanceof ResponseInterface) { + $this->sendResponse($response); + } elseif ($response instanceof PromiseInterface) { + $response->then(function (ResponseInterface $response): void { + $this->sendResponse($response); + }); + } + + Loop::run(); + } + public function requestFromGlobals(): ServerRequestInterface { $host = null; diff --git a/tests/AppTest.php b/tests/AppTest.php index 8135255..be94e29 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -8,8 +8,8 @@ use FrameworkX\ErrorHandler; use FrameworkX\Io\FiberHandler; use FrameworkX\Io\MiddlewareHandler; +use FrameworkX\Io\ReactiveHandler; use FrameworkX\Io\RouteHandler; -use FrameworkX\Io\SapiHandler; use FrameworkX\Tests\Fixtures\InvalidAbstract; use FrameworkX\Tests\Fixtures\InvalidConstructorInt; use FrameworkX\Tests\Fixtures\InvalidConstructorIntersection; @@ -24,20 +24,16 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Loop; use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Promise\Deferred; use React\Promise\Promise; use React\Promise\PromiseInterface; -use React\Socket\ConnectionInterface; -use React\Socket\Connector; use ReflectionMethod; use ReflectionProperty; use function React\Async\await; use function React\Promise\reject; use function React\Promise\resolve; -use FrameworkX\Io\LogStreamHandler; class AppTest extends TestCase { @@ -613,383 +609,41 @@ public function testConstructWithAccessLogHandlerFollowedByMiddlewareThrows(): v new App($accessLogHandler, $middleware); } - public function testRunWillReportListeningAddressAndRunLoopWithSocketServer(): void + public function testConstructWithContainerWithListenAddressWillPassListenAddressToReactiveHandler(): void { - $socket = @stream_socket_server('127.0.0.1:8080'); - if ($socket === false) { - $this->markTestSkipped('Listen address :8080 already in use'); - } - assert(is_resource($socket)); - fclose($socket); - - $app = new App(); - - $logger = $this->createMock(LogStreamHandler::class); - $logger->expects($this->atLeastOnce())->method('log')->withConsecutive(['Listening on http://127.0.0.1:8080']); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - // lovely: remove socket server on next tick to terminate loop - Loop::futureTick(function () { - $resources = get_resources(); - $socket = end($resources); - assert(is_resource($socket)); - - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - - $app->run(); - } - - public function testRunWillReportListeningAddressFromContainerEnvironmentAndRunLoopWithSocketServer(): void - { - $socket = stream_socket_server('127.0.0.1:0'); - assert(is_resource($socket)); - $addr = stream_socket_get_name($socket, false); - fclose($socket); - - $container = new Container([ - 'X_LISTEN' => $addr - ]); - - $app = new App($container); - - $logger = $this->createMock(LogStreamHandler::class); - $logger->expects($this->atLeastOnce())->method('log')->withConsecutive(['Listening on http://' . $addr]); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - // lovely: remove socket server on next tick to terminate loop - Loop::futureTick(function () { - $resources = get_resources(); - $socket = end($resources); - assert(is_resource($socket)); - - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - - $app->run(); - } - - public function testRunWillReportListeningAddressFromContainerEnvironmentWithRandomPortAndRunLoopWithSocketServer(): void - { - $container = new Container([ - 'X_LISTEN' => '127.0.0.1:0' - ]); - - $app = new App($container); - - $logger = $this->createMock(LogStreamHandler::class); - $logger->expects($this->atLeastOnce())->method('log')->withConsecutive([$this->matches('Listening on http://127.0.0.1:%d')]); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - // lovely: remove socket server on next tick to terminate loop - Loop::futureTick(function () { - $resources = get_resources(); - $socket = end($resources); - assert(is_resource($socket)); - - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - - $app->run(); - } - - public function testRunWillRestartLoopUntilSocketIsClosed(): void - { - $container = new Container([ - 'X_LISTEN' => '127.0.0.1:0' - ]); - - $app = new App($container); - - $logger = $this->createMock(LogStreamHandler::class); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - // lovely: remove socket server on next tick to terminate loop - Loop::futureTick(function () use ($logger) { - $resources = get_resources(); - $socket = end($resources); - assert(is_resource($socket)); - - Loop::futureTick(function () use ($socket) { - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - - $logger->expects($this->once())->method('log')->with('Warning: Loop restarted. Upgrade to react/async v4 recommended for production use.'); - Loop::stop(); - }); - - $app->run(); - } - - public function testRunWillListenForHttpRequestAndSendBackHttpResponseOverSocket(): void - { - $socket = stream_socket_server('127.0.0.1:0'); - assert(is_resource($socket)); - $addr = stream_socket_get_name($socket, false); - assert(is_string($addr)); - fclose($socket); - - $container = new Container([ - 'X_LISTEN' => $addr - ]); - - $app = $this->createAppWithoutLogger($container); - - $logger = $this->createMock(LogStreamHandler::class); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - Loop::futureTick(function () use ($addr): void { - $connector = new Connector(); - $connector->connect($addr)->then(function (ConnectionInterface $connection): void { - $connection->on('data', function (string $data): void { - $this->assertStringStartsWith("HTTP/1.0 404 Not Found\r\n", $data); - }); - - // lovely: remove socket server on client connection close to terminate loop - $connection->on('close', function (): void { - $resources = get_resources(); - end($resources); - prev($resources); - $socket = prev($resources); - assert(is_resource($socket)); - - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - - $connection->write("GET /unknown HTTP/1.0\r\nHost: localhost\r\n\r\n"); - }); - }); - - $app->run(); - } - - public function testRunWillReportHttpErrorForInvalidClientRequest(): void - { - $socket = stream_socket_server('127.0.0.1:0'); - assert(is_resource($socket)); - $addr = stream_socket_get_name($socket, false); - assert(is_string($addr)); - fclose($socket); - $container = new Container([ - 'X_LISTEN' => $addr + 'X_LISTEN' => '0.0.0.0:8081' ]); $app = new App($container); - $logger = $this->createMock(LogStreamHandler::class); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - Loop::futureTick(function () use ($addr, $logger): void { - $connector = new Connector(); - $connector->connect($addr)->then(function (ConnectionInterface $connection) use ($logger): void { - $logger->expects($this->once())->method('log')->with($this->matchesRegularExpression('/^HTTP error: .*$/')); - $connection->write("not a valid HTTP request\r\n\r\n"); - - // lovely: remove socket server on client connection close to terminate loop - $connection->on('close', function (): void { - $resources = get_resources(); - end($resources); - prev($resources); - $socket = prev($resources); - assert(is_resource($socket)); - - Loop::removeReadStream($socket); - fclose($socket); - - Loop::stop(); - }); - }); - }); - - $app->run(); - } - - /** - * @requires function pcntl_signal - * @requires function posix_kill - */ - public function testRunWillStopWhenReceivingSigint(): void - { - $container = new Container([ - 'X_LISTEN' => '127.0.0.1:0' - ]); - - $app = new App($container); - - $logger = $this->createMock(LogStreamHandler::class); - $logger->expects($this->exactly(2))->method('log'); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - Loop::futureTick(function () use ($logger) { - $logger->expects($this->once())->method('log')->with('Received SIGINT, stopping loop'); - - $pid = getmypid(); - assert(is_int($pid)); - posix_kill($pid, defined('SIGINT') ? SIGINT : 2); - }); - - $this->expectOutputRegex("#^\r?$#"); - $app->run(); - } - - /** - * @requires function pcntl_signal - * @requires function posix_kill - */ - public function testRunWillStopWhenReceivingSigterm(): void - { - $container = new Container([ - 'X_LISTEN' => '127.0.0.1:0' - ]); - - $app = new App($container); - - $logger = $this->createMock(LogStreamHandler::class); - - // $app->logger = $logger; - $ref = new \ReflectionProperty($app, 'logger'); - $ref->setAccessible(true); - $ref->setValue($app, $logger); - - Loop::futureTick(function () use ($logger) { - $logger->expects($this->once())->method('log')->with('Received SIGTERM, stopping loop'); - - $pid = getmypid(); - assert(is_int($pid)); - posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); - }); - - $app->run(); - } - - public function testRunAppWithEmptyAddressThrows(): void - { - $container = new Container([ - 'X_LISTEN' => '' - ]); - - $app = new App($container); - - $this->expectException(\InvalidArgumentException::class); - $app->run(); - } - - public function testRunAppWithBusyPortThrows(): void - { - $socket = stream_socket_server('127.0.0.1:0'); - assert(is_resource($socket)); - $addr = stream_socket_get_name($socket, false); - assert(is_string($addr)); - - if (@stream_socket_server($addr) !== false) { - $this->markTestSkipped('System does not prevent listening on same address twice'); - } - - $container = new Container([ - 'X_LISTEN' => $addr - ]); - - $app = new App($container); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to listen on'); - $app->run(); - } - - public function testRunOnceWillCreateRequestFromSapiThenRouteRequestAndThenSendResponseFromHandler(): void - { - $app = $this->createAppWithoutLogger(); - - $response = new Response(); - $app->get('/', function () use ($response) { - return $response; - }); - - $request = new ServerRequest('GET', 'http://example.com/'); - - $sapi = $this->createMock(SapiHandler::class); - $sapi->expects($this->once())->method('requestFromGlobals')->willReturn($request); - $sapi->expects($this->once())->method('sendResponse')->with($response); - - // $app->sapi = $sapi; + // $sapi = $app->sapi; $ref = new \ReflectionProperty($app, 'sapi'); $ref->setAccessible(true); - $ref->setValue($app, $sapi); + $sapi = $ref->getValue($app); + assert($sapi instanceof ReactiveHandler); - // $app->runOnce(); - $ref = new \ReflectionMethod($app, 'runOnce'); + // $listenAddress = $sapi->listenAddress; + $ref = new \ReflectionProperty($sapi, 'listenAddress'); $ref->setAccessible(true); - $ref->invoke($app); + $listenAddress = $ref->getValue($sapi); + + $this->assertEquals('0.0.0.0:8081', $listenAddress); } - public function testRunOnceWillCreateRequestFromSapiThenRouteRequestAndThenSendResponseFromDeferredHandler(): void + public function testRunWillExecuteRunOnSapiHandler(): void { - $app = $this->createAppWithoutLogger(); - - $response = new Response(); - $app->get('/', function () use ($response) { - return resolve($response); - }); - - $request = new ServerRequest('GET', 'http://example.com/'); + $app = new App(); - $sapi = $this->createMock(SapiHandler::class); - $sapi->expects($this->once())->method('requestFromGlobals')->willReturn($request); - $sapi->expects($this->once())->method('sendResponse')->with($response); + $sapi = $this->createMock(ReactiveHandler::class); + $sapi->expects($this->once())->method('run'); // $app->sapi = $sapi; $ref = new \ReflectionProperty($app, 'sapi'); $ref->setAccessible(true); $ref->setValue($app, $sapi); - // $app->runOnce(); - $ref = new \ReflectionMethod($app, 'runOnce'); - $ref->setAccessible(true); - $ref->invoke($app); + $app->run(); } public function testGetMethodAddsGetRouteOnRouter(): void diff --git a/tests/Io/ReactiveHandlerTest.php b/tests/Io/ReactiveHandlerTest.php new file mode 100644 index 0000000..d9b43fb --- /dev/null +++ b/tests/Io/ReactiveHandlerTest.php @@ -0,0 +1,308 @@ +markTestSkipped('Listen address :8080 already in use'); + } + assert(is_resource($socket)); + fclose($socket); + + $handler = new ReactiveHandler(null); + + $logger = $this->createMock(LogStreamHandler::class); + $logger->expects($this->atLeastOnce())->method('log')->withConsecutive(['Listening on http://127.0.0.1:8080']); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + // lovely: remove socket server on next tick to terminate loop + Loop::futureTick(function () { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $handler->run(function (): void { }); + } + + public function testRunWillReportGivenListeningAddressAndRunLoop(): void + { + $socket = stream_socket_server('127.0.0.1:0'); + assert(is_resource($socket)); + $addr = stream_socket_get_name($socket, false); + assert(is_string($addr)); + fclose($socket); + + $handler = new ReactiveHandler($addr); + + $logger = $this->createMock(LogStreamHandler::class); + $logger->expects($this->atLeastOnce())->method('log')->withConsecutive(['Listening on http://' . $addr]); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + // lovely: remove socket server on next tick to terminate loop + Loop::futureTick(function () { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $handler->run(function (): void { }); + } + + public function testRunWillReportGivenListeningAddressWithRandomPortAndRunLoop(): void + { + $handler = new ReactiveHandler('127.0.0.1:0'); + + $logger = $this->createMock(LogStreamHandler::class); + $logger->expects($this->atLeastOnce())->method('log')->withConsecutive([$this->matches('Listening on http://127.0.0.1:%d')]); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + // lovely: remove socket server on next tick to terminate loop + Loop::futureTick(function () { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $handler->run(function (): void { }); + } + + public function testRunWillRestartLoopUntilSocketIsClosed(): void + { + $handler = new ReactiveHandler('127.0.0.1:0'); + + $logger = $this->createMock(LogStreamHandler::class); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + // lovely: remove socket server on next tick to terminate loop + Loop::futureTick(function () use ($logger) { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + Loop::futureTick(function () use ($socket) { + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $logger->expects($this->once())->method('log')->with('Warning: Loop restarted. Upgrade to react/async v4 recommended for production use.'); + Loop::stop(); + }); + + $handler->run(function (): void { }); + } + + public function testRunWillListenForHttpRequestAndSendBackHttpResponseOverSocket(): void + { + $socket = stream_socket_server('127.0.0.1:0'); + assert(is_resource($socket)); + $addr = stream_socket_get_name($socket, false); + assert(is_string($addr)); + fclose($socket); + + $handler = new ReactiveHandler($addr); + + $logger = $this->createMock(LogStreamHandler::class); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + Loop::futureTick(function () use ($addr): void { + $connector = new Connector(); + $connector->connect($addr)->then(function (ConnectionInterface $connection): void { + $connection->on('data', function (string $data): void { + $this->assertEquals("HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\nOK\n", $data); + }); + + // lovely: remove socket server on client connection close to terminate loop + $connection->on('close', function (): void { + $resources = get_resources(); + end($resources); + prev($resources); + $socket = prev($resources); + assert(is_resource($socket)); + + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $connection->write("GET /unknown HTTP/1.0\r\nHost: localhost\r\n\r\n"); + }); + }); + + $handler->run(function (): Response { + return new Response(200, ['Date' => '', 'Server' => ''], "OK\n"); + }); + } + + public function testRunWillReportHttpErrorForInvalidClientRequest(): void + { + $socket = stream_socket_server('127.0.0.1:0'); + assert(is_resource($socket)); + $addr = stream_socket_get_name($socket, false); + assert(is_string($addr)); + fclose($socket); + + $handler = new ReactiveHandler($addr); + + $logger = $this->createMock(LogStreamHandler::class); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + Loop::futureTick(function () use ($addr, $logger): void { + $connector = new Connector(); + $connector->connect($addr)->then(function (ConnectionInterface $connection) use ($logger): void { + $logger->expects($this->once())->method('log')->with($this->matchesRegularExpression('/^HTTP error: .*$/')); + $connection->write("not a valid HTTP request\r\n\r\n"); + + // lovely: remove socket server on client connection close to terminate loop + $connection->on('close', function (): void { + $resources = get_resources(); + end($resources); + prev($resources); + $socket = prev($resources); + assert(is_resource($socket)); + + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + }); + }); + + $handler->run(function (): void { }); + } + + /** + * @requires function pcntl_signal + * @requires function posix_kill + */ + public function testRunWillStopWhenReceivingSigint(): void + { + $handler = new ReactiveHandler('127.0.0.1:0'); + + $logger = $this->createMock(LogStreamHandler::class); + $logger->expects($this->exactly(2))->method('log'); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + Loop::futureTick(function () use ($logger) { + $logger->expects($this->once())->method('log')->with('Received SIGINT, stopping loop'); + + $pid = getmypid(); + assert(is_int($pid)); + posix_kill($pid, defined('SIGINT') ? SIGINT : 2); + }); + + $this->expectOutputRegex("#^\r?$#"); + $handler->run(function (): void { }); + } + + /** + * @requires function pcntl_signal + * @requires function posix_kill + */ + public function testRunWillStopWhenReceivingSigterm(): void + { + $handler = new ReactiveHandler('127.0.0.1:0'); + + $logger = $this->createMock(LogStreamHandler::class); + + // $handler->logger = $logger; + $ref = new \ReflectionProperty($handler, 'logger'); + $ref->setAccessible(true); + $ref->setValue($handler, $logger); + + Loop::futureTick(function () use ($logger) { + $logger->expects($this->once())->method('log')->with('Received SIGTERM, stopping loop'); + + $pid = getmypid(); + assert(is_int($pid)); + posix_kill($pid, defined('SIGTERM') ? SIGTERM : 15); + }); + + $handler->run(function (): void { }); + } + + public function testRunWithEmptyAddressThrows(): void + { + $handler = new ReactiveHandler(''); + + $this->expectException(\InvalidArgumentException::class); + $handler->run(function (): void { }); + } + + public function testRunWithBusyPortThrows(): void + { + $socket = stream_socket_server('127.0.0.1:0'); + assert(is_resource($socket)); + $addr = stream_socket_get_name($socket, false); + assert(is_string($addr)); + + if (@stream_socket_server($addr) !== false) { + $this->markTestSkipped('System does not prevent listening on same address twice'); + } + + $handler = new ReactiveHandler($addr); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to listen on'); + $handler->run(function (): void { }); + } +} diff --git a/tests/Io/SapiHandlerTest.php b/tests/Io/SapiHandlerTest.php index 8603cce..912d46d 100644 --- a/tests/Io/SapiHandlerTest.php +++ b/tests/Io/SapiHandlerTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use React\Http\Message\Response; use React\Stream\ThroughStream; +use function React\Promise\resolve; class SapiHandlerTest extends TestCase { @@ -379,4 +380,42 @@ public function testSendResponseSetsMultipleCookieHeaders(): void $this->assertEquals(['Content-Type:', 'Set-Cookie: 1=1', 'Set-Cookie: 2=2'], xdebug_get_headers()); } + + public function testRunWillSendResponseHeadersFromHandler(): void + { + if (headers_sent() || !function_exists('xdebug_get_headers')) { + $this->markTestSkipped('Test requires running PHPUnit with Xdebug enabled'); + } + + $sapi = new SapiHandler(); + + header_remove(); + $_SERVER['SERVER_PROTOCOL'] = 'http/1.1'; + + $this->expectOutputString(''); + $sapi->run(function () { + return new Response(); + }); + + $this->assertEquals(['Content-Type:', 'Content-Length: 0'], xdebug_get_headers()); + } + + public function testRunWillSendResponseHeadersFromDeferredHandler(): void + { + if (headers_sent() || !function_exists('xdebug_get_headers')) { + $this->markTestSkipped('Test requires running PHPUnit with Xdebug enabled'); + } + + $sapi = new SapiHandler(); + + header_remove(); + $_SERVER['SERVER_PROTOCOL'] = 'http/1.1'; + + $this->expectOutputString(''); + $sapi->run(function () { + return resolve(new Response()); + }); + + $this->assertEquals(['Content-Type:', 'Content-Length: 0'], xdebug_get_headers()); + } }