From 265a4651916608387a81a0648539f3112e23eb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 23 Sep 2026 16:42:08 +0200 Subject: [PATCH 1/2] Avoid listening on address when creating HTTP server fails --- src/Runner/HttpServerRunner.php | 6 +++-- tests/Runner/HttpServerRunnerTest.php | 37 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Runner/HttpServerRunner.php b/src/Runner/HttpServerRunner.php index f3aa15f..772c511 100644 --- a/src/Runner/HttpServerRunner.php +++ b/src/Runner/HttpServerRunner.php @@ -41,13 +41,15 @@ public function __construct(LogStreamHandler $logger, ?string $listenAddress) /** * @param callable(\Psr\Http\Message\ServerRequestInterface):(\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface<\Psr\Http\Message\ResponseInterface>) $handler * @return void + * @throws \InvalidArgumentException if listen address or PHP's ini settings are invalid + * @throws \RuntimeException if listening on the given listen address fails */ public function __invoke(callable $handler): void { - $socket = new SocketServer($this->listenAddress); - // create HTTP server, automatically start new fiber for each request on PHP 8.1+ $http = new HttpServer(...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler])); + + $socket = new SocketServer($this->listenAddress); $http->listen($socket); $logger = $this->logger; diff --git a/tests/Runner/HttpServerRunnerTest.php b/tests/Runner/HttpServerRunnerTest.php index 65a82ca..ef98683 100644 --- a/tests/Runner/HttpServerRunnerTest.php +++ b/tests/Runner/HttpServerRunnerTest.php @@ -367,4 +367,41 @@ public function testInvokeWithBusyPortThrows(): void throw new \BadFunctionCallException('Should not be reached'); }); } + + public function testInvokeWithInvalidMemoryLimitThrowsWithoutListeningOnAddress(): 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); + + $logger = $this->createMock(LogStreamHandler::class); + assert($logger instanceof LogStreamHandler); + + $runner = new HttpServerRunner($logger, $addr); + + // PHP accepts this invalid memory limit, but ReactPHP's HTTP server rejects it + $memoryLimit = ini_get('memory_limit'); + assert(is_string($memoryLimit)); + @ini_set('memory_limit', '-2G'); + + $exception = null; + try { + $runner(function (): void { + throw new \BadFunctionCallException('Should not be reached'); + }); + } catch (\InvalidArgumentException $e) { + $exception = $e; + } finally { + ini_set('memory_limit', $memoryLimit); + } + + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + + $socket = @stream_socket_server($addr); + $this->assertNotFalse($socket); + assert(is_resource($socket)); + fclose($socket); + } } From e7337f933dc10066bd7fca956093cd8066b5a6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 1 Jan 2026 21:33:36 +0100 Subject: [PATCH 2/2] Support experimental HTTP middleware in `HttpServerRunner` constructor --- docs/api/request.md | 24 +++++- docs/best-practices/deployment.md | 71 +++++++++++++++++ src/Runner/HttpServerRunner.php | 39 +++++++-- tests/Runner/HttpServerRunnerTest.php | 110 ++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/api/request.md b/docs/api/request.md index f50e048..e350804 100644 --- a/docs/api/request.md +++ b/docs/api/request.md @@ -88,6 +88,14 @@ to be sure the client intended to send a JSON request body. This example returns a simple text response, you may also want to return a [JSON response](response.md#json) for common API usage. +> ℹ️ **Info** +> +> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server) +> limits HTTP request bodies to 64 KiB by default. Any JSON above this size will +> show up as an empty request body. See +> [request body limit](../best-practices/deployment.md#request-body-limit) for +> how to change this limit. + ## Form data You can access HTML form data from the HTTP request body like this: @@ -116,6 +124,14 @@ Hello Alice! This method returns a possibly nested array of form fields, very similar to PHP's `$_POST` superglobal. +> ℹ️ **Info** +> +> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server) +> limits HTTP request bodies to 64 KiB by default. Any form data above this size +> will show up as an empty request body with no form fields whatsoever. See +> [request body limit](../best-practices/deployment.md#request-body-limit) for +> how to change this limit. + ## Uploads You can access any file uploads from HTML forms like this: @@ -169,9 +185,11 @@ assert(is_string($type) || $name === null); > ℹ️ **Info** > -> Note that HTTP requests are currently limited to 64 KiB. Any uploads above -> this size will currently show up as an empty request body with no file uploads -> whatsoever. +> Note that the [built-in web server](../best-practices/deployment.md#built-in-web-server) +> limits HTTP request bodies to 64 KiB by default. Any uploads above this size +> will show up as an empty request body with no file uploads whatsoever. See +> [request body limit](../best-practices/deployment.md#request-body-limit) for +> how to change this limit. ## Headers diff --git a/docs/best-practices/deployment.md b/docs/best-practices/deployment.md index 6d926e3..dffa44d 100644 --- a/docs/best-practices/deployment.md +++ b/docs/best-practices/deployment.md @@ -321,6 +321,77 @@ $ sudoedit /etc/php/8.5/cli/php.ini + memory_limit = -1 ``` +### Request body limit + +By default, the built-in web server buffers each request body in memory and +limits each request body to 64 KiB. This applies to +[JSON](../api/request.md#json), [form data](../api/request.md#form-data) and +[file uploads](../api/request.md#uploads) alike. + +If your application needs to accept larger request bodies, you can pass a custom +list of [ReactPHP HTTP middleware](https://reactphp.org/http/#middleware) to the +HTTP server runner in your +[Container configuration](controllers.md#container-configuration) like this: + +```php title="public/index.php" + function (?string $X_LISTEN = null) { + return new FrameworkX\Runner\HttpServerRunner( + new FrameworkX\Io\LogStreamHandler('php://output'), + $X_LISTEN, + [ + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(10), + new React\Http\Middleware\RequestBodyBufferMiddleware('8M'), + new React\Http\Middleware\RequestBodyParserMiddleware('2M') + ] + ); + } +]); + +$app = new FrameworkX\App($container); + +// … + +$app->run(); +``` + +This example accepts request bodies of up to 8 MiB in total with file uploads of +up to 2 MiB each, matching PHP's defaults, and processes at most 10 requests +concurrently, so the server holds at most 80 MiB of buffered request bodies at +any time. Make sure your [memory limit](#memory-limit) leaves room for this. +Raising the limit requires a +[`StreamingRequestMiddleware`](https://reactphp.org/http/#streamingrequestmiddleware) +in the list, otherwise ReactPHP's own 64 KiB buffer runs first and the larger +buffer never sees a request body above 64 KiB. + +> ⚠️ **Feature preview** +> +> Note that this is an experimental API that may be subject to change in future +> releases. The given list runs in front of X, so any middleware can preprocess +> or filter requests before X handles them (a middleware that does not call +> `$next` means X never sees the request). Keep the resulting configuration sound: +> +> * A [`StreamingRequestMiddleware`](https://reactphp.org/http/#streamingrequestmiddleware) +> replaces *all* of ReactPHP's default middleware, so the order of the +> remaining middleware matters. +> * Without [`LimitConcurrentRequestsMiddleware`](https://reactphp.org/http/#limitconcurrentrequestsmiddleware), +> the number of buffered requests is unbounded and concurrent uploads can +> exhaust your memory. +> * Without [`RequestBodyParserMiddleware`](https://reactphp.org/http/#requestbodyparsermiddleware), +> [form data](../api/request.md#form-data) and +> [file uploads](../api/request.md#uploads) are no longer parsed. +> +> Behind a [traditional web server](#traditional-stacks), PHP's +> [`post_max_size`](https://www.php.net/manual/en/ini.core.php#ini.post-max-size) +> (default `8M`) and +> [`upload_max_filesize`](https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize) +> (default `2M`) settings apply instead. + ### FD limits By default, many systems limit the number of file descriptors (FDs) that a diff --git a/src/Runner/HttpServerRunner.php b/src/Runner/HttpServerRunner.php index 772c511..dab724a 100644 --- a/src/Runner/HttpServerRunner.php +++ b/src/Runner/HttpServerRunner.php @@ -18,8 +18,10 @@ * incoming connections simultaneously. The long-running server process will * continue to run until it is interrupted by a signal. * - * Note that this is an internal class only and nothing you should usually have - * to care about. See also the `App` and `SapiRunner` for more details. + * Note that this is mostly an internal class and nothing you should usually + * have to care about. For more advanced use cases, its constructor offers an + * experimental API to customize the HTTP server, such as raising the request + * body limit. See also the `App` and `SapiRunner` for more details. * * @internal */ @@ -31,11 +33,32 @@ class HttpServerRunner /** @var string */ private $listenAddress; - /** @throws void */ - public function __construct(LogStreamHandler $logger, ?string $listenAddress) + /** @var list */ + private $experimentalHttpMiddleware; + + /** + * [Experimental] Create an HTTP server runner with custom configuration + * + * @param list $experimentalHttpMiddleware (optional) list of ReactPHP HTTP middleware to run in front of the application + * @throws \TypeError if given $experimentalHttpMiddleware is invalid + */ + public function __construct(LogStreamHandler $logger, ?string $listenAddress, array $experimentalHttpMiddleware = []) { + if ($experimentalHttpMiddleware !== \array_values($experimentalHttpMiddleware)) { + throw new \TypeError('Argument #3 ($experimentalHttpMiddleware) must be of type list, array given'); + } + foreach ($experimentalHttpMiddleware as $key => $middleware) { + /** @var mixed $middleware explicit type check for mixed if user ignores parameter type */ + if (!\is_callable($middleware)) { + throw new \TypeError( + 'Argument #3 ($experimentalHttpMiddleware) for key ' . $key . ' must be of type callable, ' . (\is_object($middleware) ? \get_class($middleware) : \gettype($middleware)) . ' given' + ); + } + } + $this->logger = $logger; $this->listenAddress = $listenAddress ?? '127.0.0.1:8080'; + $this->experimentalHttpMiddleware = $experimentalHttpMiddleware; } /** @@ -46,8 +69,12 @@ public function __construct(LogStreamHandler $logger, ?string $listenAddress) */ public function __invoke(callable $handler): void { - // create HTTP server, automatically start new fiber for each request on PHP 8.1+ - $http = new HttpServer(...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler])); + // create HTTP server, run any experimental middleware in front of the + // application and automatically start new fiber for each request on PHP 8.1+ + $http = new HttpServer( + ...$this->experimentalHttpMiddleware, + ...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler]) + ); $socket = new SocketServer($this->listenAddress); $http->listen($socket); diff --git a/tests/Runner/HttpServerRunnerTest.php b/tests/Runner/HttpServerRunnerTest.php index ef98683..c3e4f59 100644 --- a/tests/Runner/HttpServerRunnerTest.php +++ b/tests/Runner/HttpServerRunnerTest.php @@ -5,8 +5,11 @@ use FrameworkX\Io\LogStreamHandler; use FrameworkX\Runner\HttpServerRunner; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Loop; use React\Http\Message\Response; +use React\Http\Middleware\RequestBodyBufferMiddleware; +use React\Http\Middleware\StreamingRequestMiddleware; use React\Promise\Promise; use React\Socket\ConnectionInterface; use React\Socket\Connector; @@ -14,6 +17,26 @@ class HttpServerRunnerTest extends TestCase { + public function testCtorWithHttpMiddlewareWithStringKeysThrows(): void + { + $logger = $this->createMock(LogStreamHandler::class); + assert($logger instanceof LogStreamHandler); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #3 ($experimentalHttpMiddleware) must be of type list, array given'); + new HttpServerRunner($logger, '127.0.0.1:0', ['foo' => function () { }]); // @phpstan-ignore-line + } + + public function testCtorWithInvalidHttpMiddlewareThrows(): void + { + $logger = $this->createMock(LogStreamHandler::class); + assert($logger instanceof LogStreamHandler); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #3 ($experimentalHttpMiddleware) for key 1 must be of type callable, stdClass given'); + new HttpServerRunner($logger, '127.0.0.1:0', [function () { }, new \stdClass()]); // @phpstan-ignore-line + } + public function testInvokeWillReportDefaultListeningAddressAndRunLoop(): void { $socket = @stream_socket_server('127.0.0.1:8080'); @@ -176,6 +199,93 @@ public function testInvokeWillListenForHttpRequestAndSendBackHttpResponseOverSoc }); } + public function testInvokeWithHttpMiddlewareWillPassRequestThroughMiddlewareBeforeHandler(): void + { + $logger = $this->createMock(LogStreamHandler::class); + assert($logger instanceof LogStreamHandler); + + $middleware = function (ServerRequestInterface $request, callable $next) { + return $next($request->withAttribute('middleware', 'yes')); + }; + + $runner = new HttpServerRunner($logger, '127.0.0.1:0', [$middleware]); + + Loop::futureTick(function (): void { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + $connector = new Connector(); + $promise = $connector->connect((string) stream_socket_get_name($socket, false)); + + $promise->then(function (ConnectionInterface $connection) use ($socket): void { + // lovely: remove socket server on client connection close to terminate loop + $connection->on('close', function () use ($socket): void { + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $connection->write("GET /unknown HTTP/1.0\r\nHost: localhost\r\n\r\n"); + }); + }); + + $attribute = null; + $runner(function (ServerRequestInterface $request) use (&$attribute): Response { + $attribute = $request->getAttribute('middleware'); + + return new Response(200, ['Date' => '', 'Server' => ''], "OK\n"); + }); + + $this->assertEquals('yes', $attribute); + } + + public function testInvokeWithHttpMiddlewareWillReceiveRequestBodyAboveDefaultBufferSize(): void + { + $logger = $this->createMock(LogStreamHandler::class); + assert($logger instanceof LogStreamHandler); + + $runner = new HttpServerRunner($logger, '127.0.0.1:0', [ + new StreamingRequestMiddleware(), + new RequestBodyBufferMiddleware('1M') + ]); + + $body = str_repeat('x', 100000); + Loop::futureTick(function () use ($body): void { + $resources = get_resources(); + $socket = end($resources); + assert(is_resource($socket)); + + $connector = new Connector(); + $promise = $connector->connect((string) stream_socket_get_name($socket, false)); + + $promise->then(function (ConnectionInterface $connection) use ($socket, $body): void { + // lovely: remove socket server on client connection close to terminate loop + $connection->on('close', function () use ($socket): void { + Loop::removeReadStream($socket); + fclose($socket); + + Loop::stop(); + }); + + $connection->write("POST /unknown HTTP/1.0\r\nHost: localhost\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body); + }); + }); + + $length = null; + $fiber = null; + $runner(function (ServerRequestInterface $request) use (&$length, &$fiber): Response { + $length = $request->getBody()->getSize(); + $fiber = \PHP_VERSION_ID >= 80100 ? \Fiber::getCurrent() : false; + + return new Response(200, ['Date' => '', 'Server' => ''], "OK\n"); + }); + + $this->assertEquals(100000, $length); + $this->assertNotNull($fiber); + } + public function testInvokeWillOnlyRestartLoopAfterAwaitingWhenFibersAreNotAvailable(): void { $socket = stream_socket_server('127.0.0.1:0');