From 2f9cc14e5031ca76f165e6e4aacad657f1b50be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Fri, 18 Sep 2026 16:07:19 +0300 Subject: [PATCH 01/42] feat: add Symfony Runtime for Rapira workers --- .editorconfig | 15 ++ .github/workflows/quality.yml | 41 +++++ .github/workflows/testing.yml | 41 +++++ .gitignore | 6 + .php-cs-fixer.dist.php | 12 ++ LICENSE.md | 30 ++++ README.md | 91 ++++++++++ composer.json | 64 +++++++ psalm.xml | 24 +++ src/Internal/NativeRequestLoop.php | 22 +++ src/Internal/RequestLoop.php | 18 ++ src/Runner.php | 60 +++++++ src/Runtime.php | 76 ++++++++ testo.php | 20 +++ tests/Support/NullKernel.php | 17 ++ tests/Support/RuntimeForMode.php | 23 +++ tests/Unit/ARuntimeTest.php | 54 ++++++ tests/Unit/RunnerTest.php | 269 +++++++++++++++++++++++++++++ 18 files changed, 883 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/quality.yml create mode 100644 .github/workflows/testing.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 psalm.xml create mode 100644 src/Internal/NativeRequestLoop.php create mode 100644 src/Internal/RequestLoop.php create mode 100644 src/Runner.php create mode 100644 src/Runtime.php create mode 100644 testo.php create mode 100644 tests/Support/NullKernel.php create mode 100644 tests/Support/RuntimeForMode.php create mode 100644 tests/Unit/ARuntimeTest.php create mode 100644 tests/Unit/RunnerTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..faa062e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yaml,yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..d32a6b7 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,41 @@ +--- + +name: Quality and security + +on: + pull_request: + push: + branches: + - main + +jobs: + quality: + timeout-minutes: 5 + runs-on: ubuntu-latest + env: + COMPOSER_ROOT_VERSION: 1.x-dev + steps: + - name: Check out the codebase + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + ini-values: error_reporting=E_ALL + coverage: none + + - name: Validate composer metadata + run: composer validate --ansi --strict + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Check code style + run: composer cs:diff + + - name: Run static analysis + run: composer psalm:ci + + - name: Audit dependencies + run: composer audit --ansi diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..6eb5ec8 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,41 @@ +--- + +name: Unit testing + +on: + pull_request: + push: + branches: + - main + +jobs: + testing: + timeout-minutes: 5 + runs-on: ubuntu-latest + env: + COMPOSER_ROOT_VERSION: 1.x-dev + strategy: + fail-fast: false + matrix: + php-version: + - '8.4' + - '8.5' + steps: + - name: Check out the codebase + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + ini-values: error_reporting=E_ALL + coverage: none + + - name: Validate composer metadata + run: composer validate --ansi --strict + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run unit tests + run: composer test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b70909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.*/ +!/.github/ +/runtime/ +/vendor/ +/composer.lock +*.log diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4f7ae81 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,12 @@ +include(__DIR__ . '/src') + ->include(__DIR__ . '/tests') + ->include(__DIR__ . '/testo.php') + ->include(__FILE__) + ->build(); diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..fad9923 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,30 @@ +# BSD 3-Clause + +Copyright (c) 2026, Aleksei Gagarin (roxblnfk). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of Aleksei Gagarin (roxblnfk) nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc56569 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# rapira/symfony + +Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker mode. +It boots one Symfony kernel and uses it for sequential requests handled by a +resident Rapira process. The standard Symfony `public/index.php` stays unchanged. + +## Installation + +The Rapira contract currently has no stable release, so require it explicitly: + +```shell +composer require rapira/symfony rapira/contract:dev-master +``` + +The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. + +## Application setup + +Symfony's Runtime component reads `APP_RUNTIME` before it loads +`vendor/autoload_runtime.php`. Define it in the process environment; setting it +inside `public/index.php` after the runtime autoloader is required is too late. + +```dotenv +APP_RUNTIME=Rapira\Symfony\Runtime +``` + +Keep the normal Symfony front controller: + +```php +=8.4", + "rapira/contract": "dev-master", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/runtime": "^7.4 || ^8.0" + }, + "require-dev": { + "spiral/code-style": "^2.3.1", + "testo/testo": "^0.10.42", + "vimeo/psalm": "^7" + }, + "autoload": { + "psr-4": { + "Rapira\\Symfony\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Rapira\\Symfony\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + }, + "runtime": { + "class": "Rapira\\Symfony\\Runtime" + } + }, + "config": { + "allow-plugins": { + "symfony/runtime": true + }, + "sort-packages": true + }, + "scripts": { + "cs:diff": "php-cs-fixer fix --dry-run -v --diff", + "cs:fix": "php-cs-fixer fix -v", + "psalm": "psalm", + "psalm:ci": "psalm --output-format=github --show-info=false --stats --threads=4", + "test": "testo" + } +} diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..af03bf7 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Internal/NativeRequestLoop.php b/src/Internal/NativeRequestLoop.php new file mode 100644 index 0000000..3c92bae --- /dev/null +++ b/src/Internal/NativeRequestLoop.php @@ -0,0 +1,22 @@ + $server */ + $server = \array_filter( + $_SERVER, + static fn(mixed $key): bool => !\str_starts_with((string) $key, 'HTTP_'), + \ARRAY_FILTER_USE_KEY, + ); + + while (true) { + $request = null; + $response = null; + + $handled = $this->requestLoop->handle(function () use ($server, &$request, &$response): bool { + $_SERVER += $server; + + $request = Request::createFromGlobals(); + $response = $this->kernel->handle($request); + $response->send(); + + return true; + }); + + if (!$handled) { + break; + } + + if ($this->kernel instanceof TerminableInterface && $request !== null && $response !== null) { + $this->kernel->terminate($request, $response); + } + } + + return 0; + } +} diff --git a/src/Runtime.php b/src/Runtime.php new file mode 100644 index 0000000..67cbc0b --- /dev/null +++ b/src/Runtime.php @@ -0,0 +1,76 @@ +mode = $this->getMode(); + + if ($this->mode === Mode::Worker) { + $_SERVER['APP_RUNTIME_MODE'] = 'web=1&worker=1'; + } + + parent::__construct($options); + } + + #[\Override] + public function getRunner(?object $application): RunnerInterface + { + if (!$application instanceof HttpKernelInterface) { + return parent::getRunner($application); + } + + return match ($this->mode) { + Mode::Classic => parent::getRunner($application), + Mode::Worker => new Runner($application, new NativeRequestLoop()), + Mode::Dispatcher => throw new \LogicException( + 'Rapira Dispatcher mode is not supported for Symfony HttpKernel applications.', + ), + }; + } + + /** + * @psalm-suppress MissingPureAnnotation + * @psalm-suppress UndefinedFunction The function is provided by the Rapira extension and its contract. + * @psalm-suppress MixedReturnStatement + */ + protected function getMode(): Mode + { + return \Rapira\get_mode(); + } +} diff --git a/testo.php b/testo.php new file mode 100644 index 0000000..d175ef7 --- /dev/null +++ b/testo.php @@ -0,0 +1,20 @@ +runtimeMode; + } +} diff --git a/tests/Unit/ARuntimeTest.php b/tests/Unit/ARuntimeTest.php new file mode 100644 index 0000000..03f5f6a --- /dev/null +++ b/tests/Unit/ARuntimeTest.php @@ -0,0 +1,54 @@ + false, 'error_handler' => false]); + + Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); + Assert::true($runtime->getRunner(new NullKernel()) instanceof Runner); + } + + public function classicModeDelegatesToSymfonyRuntime(): void + { + unset($_SERVER['APP_RUNTIME_MODE']); + + $runtime = new RuntimeForMode(Mode::Classic, ['debug' => false, 'error_handler' => false]); + + Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); + Assert::true($runtime->getRunner(new NullKernel()) instanceof HttpKernelRunner); + } + + public function nonHttpApplicationsDelegateInWorkerMode(): void + { + $runtime = new RuntimeForMode(Mode::Worker, ['debug' => false, 'error_handler' => false]); + + Assert::same($runtime->getRunner(static fn(): int => 17)->run(), 17); + } + + public function dispatcherModeRejectsHttpKernelApplications(): void + { + $runtime = new RuntimeForMode(Mode::Dispatcher, ['debug' => false, 'error_handler' => false]); + + Expect::exception(\LogicException::class) + ->withMessage('Rapira Dispatcher mode is not supported for Symfony HttpKernel applications.'); + $runtime->getRunner(new NullKernel()); + } +} diff --git a/tests/Unit/RunnerTest.php b/tests/Unit/RunnerTest.php new file mode 100644 index 0000000..1b76b31 --- /dev/null +++ b/tests/Unit/RunnerTest.php @@ -0,0 +1,269 @@ + 'test', + 'APP_SECRET' => 'boot-secret', + 'HTTP_BOOT_ONLY' => 'must-not-leak', + ]; + $kernel = new RecordingKernel(); + $loop = new FakeRequestLoop([ + [ + 'server' => [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/first?q=one', + 'QUERY_STRING' => 'q=one', + 'HTTP_HOST' => 'localhost', + 'HTTP_X_REQUEST' => 'first', + 'APP_ENV' => 'request-env', + ], + 'get' => ['q' => 'one'], + 'post' => [], + 'cookie' => ['session' => 'first'], + ], + [ + 'server' => [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/second?q=two', + 'QUERY_STRING' => 'q=two', + 'HTTP_HOST' => 'localhost', + 'HTTP_X_REQUEST' => 'second', + ], + 'get' => ['q' => 'two'], + 'post' => ['body' => 'second'], + 'cookie' => ['session' => 'second'], + ], + ]); + + \ob_start(); + $result = (new Runner($kernel, $loop))->run(); + $output = (string) \ob_get_clean(); + + Assert::same($result, 0); + Assert::same($output, 'response-1response-2'); + Assert::same($kernel->handled, [ + [ + 'id' => $kernel->handled[0]['id'], + 'method' => 'GET', + 'path' => '/first', + 'query' => 'one', + 'body' => null, + 'cookie' => 'first', + 'header' => 'first', + 'app_env' => 'request-env', + 'secret' => 'boot-secret', + 'boot_header' => null, + ], + [ + 'id' => $kernel->handled[1]['id'], + 'method' => 'POST', + 'path' => '/second', + 'query' => 'two', + 'body' => 'second', + 'cookie' => 'second', + 'header' => 'second', + 'app_env' => 'test', + 'secret' => 'boot-secret', + 'boot_header' => null, + ], + ]); + Assert::false($kernel->handled[0]['id'] === $kernel->handled[1]['id']); + Assert::same($loop->handlers, 2); + } + + public function sendsThenTerminatesExactlyOncePerRequestAndDrains(): void + { + $_SERVER = ['APP_ENV' => 'test']; + $events = new Events(); + $kernel = new TerminableRecordingKernel($events); + $loop = new FakeRequestLoop([$this->requestGlobals('/lifecycle')], $events); + + \ob_start(); + $result = (new Runner($kernel, $loop))->run(); + \ob_end_clean(); + + Assert::same($result, 0); + Assert::same($events->values, ['loop:before', 'handle', 'send', 'loop:after', 'terminate', 'drain']); + Assert::same($kernel->terminateCalls, 1); + } + + public function propagatesExceptionsWithoutTerminatingOrContinuing(): void + { + $_SERVER = ['APP_ENV' => 'test']; + $events = new Events(); + $kernel = new ThrowingKernel($events); + $loop = new FakeRequestLoop([ + $this->requestGlobals('/failure'), + $this->requestGlobals('/must-not-run'), + ], $events); + + Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); + try { + (new Runner($kernel, $loop))->run(); + } finally { + Assert::same($events->values, ['loop:before', 'handle']); + Assert::same($kernel->terminateCalls, 0); + Assert::same($loop->handlers, 1); + } + } + + /** + * @return array{server: array, get: array{}, post: array{}, cookie: array{}} + */ + private function requestGlobals(string $path): array + { + return [ + 'server' => [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => $path, + 'HTTP_HOST' => 'localhost', + ], + 'get' => [], + 'post' => [], + 'cookie' => [], + ]; + } +} + +final class FakeRequestLoop implements RequestLoop +{ + public int $handlers = 0; + + /** + * @param list, get: array, post: array, cookie: array}> $requests + */ + public function __construct( + private array $requests, + private readonly ?Events $events = null, + ) {} + + public function handle(callable $handler): bool + { + $request = \array_shift($this->requests); + if ($request === null) { + if ($this->events !== null) { + $this->events->values[] = 'drain'; + } + + return false; + } + + $_SERVER = $request['server']; + $_GET = $request['get']; + $_POST = $request['post']; + $_COOKIE = $request['cookie']; + $_FILES = []; + ++$this->handlers; + if ($this->events !== null) { + $this->events->values[] = 'loop:before'; + } + $handler(); + if ($this->events !== null) { + $this->events->values[] = 'loop:after'; + } + + return true; + } +} + +class RecordingKernel implements HttpKernelInterface +{ + /** @var list> */ + public array $handled = []; + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->handled[] = [ + 'id' => \spl_object_id($request), + 'method' => $request->getMethod(), + 'path' => $request->getPathInfo(), + 'query' => $request->query->get('q'), + 'body' => $request->request->get('body'), + 'cookie' => $request->cookies->get('session'), + 'header' => $request->headers->get('x-request'), + 'app_env' => $request->server->get('APP_ENV'), + 'secret' => $request->server->get('APP_SECRET'), + 'boot_header' => $request->headers->get('boot-only'), + ]; + + return new Response('response-' . \count($this->handled)); + } +} + +final class TerminableRecordingKernel implements HttpKernelInterface, TerminableInterface +{ + public int $terminateCalls = 0; + + public function __construct(private readonly Events $events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->events->values[] = 'handle'; + + return new EventResponse($this->events); + } + + public function terminate(Request $request, Response $response): void + { + ++$this->terminateCalls; + $this->events->values[] = 'terminate'; + } +} + +final class ThrowingKernel implements HttpKernelInterface, TerminableInterface +{ + public int $terminateCalls = 0; + + public function __construct(private readonly Events $events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->events->values[] = 'handle'; + + throw new \RuntimeException('kernel failed'); + } + + public function terminate(Request $request, Response $response): void + { + ++$this->terminateCalls; + } +} + +final class EventResponse extends Response +{ + public function __construct(private readonly Events $events) + { + parent::__construct(); + } + + public function send(bool $flush = true): static + { + $this->events->values[] = 'send'; + + return $this; + } +} + +final class Events +{ + /** @var list */ + public array $values = []; +} From 0dcc0c04d6682d9615e261e7bd459298c0111350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Fri, 18 Sep 2026 16:20:55 +0300 Subject: [PATCH 02/42] fix: harden Rapira worker lifecycle and bootstrap --- README.md | 51 +++++++++++++++++--------- composer.json | 3 -- src/Internal/autoload_runtime.template | 33 +++++++++++++++++ src/Runner.php | 2 + src/Runtime.php | 1 + tests/Unit/RunnerTest.php | 42 ++++++++++++++++++++- 6 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 src/Internal/autoload_runtime.template diff --git a/README.md b/README.md index cc56569..2a21636 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,27 @@ The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. ## Application setup -Symfony's Runtime component reads `APP_RUNTIME` before it loads -`vendor/autoload_runtime.php`. Define it in the process environment; setting it -inside `public/index.php` after the runtime autoloader is required is too late. - -```dotenv -APP_RUNTIME=Rapira\Symfony\Runtime +This package provides a Rapira-aware Symfony Runtime and runtime autoload +template. Add the following project-level Symfony Runtime configuration to the +application's `composer.json`: + +```json +{ + "extra": { + "runtime": { + "autoload_template": "vendor/rapira/symfony/src/Internal/autoload_runtime.template", + "class": "Rapira\\Symfony\\Runtime" + } + } +} ``` -Keep the normal Symfony front controller: +Run `composer dump-autoload` after adding it. Composer then generates +`vendor/autoload_runtime.php` with the Runtime class and conventional +`public/index.php` entrypoint baked in. This is important because Rapira exposes +`SCRIPT_FILENAME` only while handling a request, after the worker has already +booted. No `APP_RUNTIME` environment variable or custom bootstrap script is +required. Keep the normal Symfony front controller: ```php getResolver($app) + ->resolve(); + +$app = $app(...$args); + +exit( + $runtime + ->getRunner($app) + ->run(), +); diff --git a/src/Runner.php b/src/Runner.php index 08c8525..4082d2d 100644 --- a/src/Runner.php +++ b/src/Runner.php @@ -53,6 +53,8 @@ public function run(): int if ($this->kernel instanceof TerminableInterface && $request !== null && $response !== null) { $this->kernel->terminate($request, $response); } + + \gc_collect_cycles(); } return 0; diff --git a/src/Runtime.php b/src/Runtime.php index 67cbc0b..1fa0d72 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -12,6 +12,7 @@ /** * @api + * @psalm-consistent-constructor */ class Runtime extends SymfonyRuntime { diff --git a/tests/Unit/RunnerTest.php b/tests/Unit/RunnerTest.php index 1b76b31..296de6c 100644 --- a/tests/Unit/RunnerTest.php +++ b/tests/Unit/RunnerTest.php @@ -101,10 +101,29 @@ public function sendsThenTerminatesExactlyOncePerRequestAndDrains(): void \ob_end_clean(); Assert::same($result, 0); - Assert::same($events->values, ['loop:before', 'handle', 'send', 'loop:after', 'terminate', 'drain']); + Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate', 'drain']); Assert::same($kernel->terminateCalls, 1); } + public function terminateExceptionsPropagateBeforeCollectionAndTheNextRequest(): void + { + $_SERVER = ['APP_ENV' => 'test']; + $events = new Events(); + $kernel = new TerminateThrowingKernel($events); + $loop = new FakeRequestLoop([ + $this->requestGlobals('/failure'), + $this->requestGlobals('/must-not-run'), + ], $events); + + Expect::exception(\RuntimeException::class)->withMessage('terminate failed'); + try { + (new Runner($kernel, $loop))->run(); + } finally { + Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate']); + Assert::same($loop->handlers, 1); + } + } + public function propagatesExceptionsWithoutTerminatingOrContinuing(): void { $_SERVER = ['APP_ENV' => 'test']; @@ -228,6 +247,25 @@ public function terminate(Request $request, Response $response): void } } +final class TerminateThrowingKernel implements HttpKernelInterface, TerminableInterface +{ + public function __construct(private readonly Events $events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->events->values[] = 'handle'; + + return new EventResponse($this->events); + } + + public function terminate(Request $request, Response $response): void + { + $this->events->values[] = 'terminate'; + + throw new \RuntimeException('terminate failed'); + } +} + final class ThrowingKernel implements HttpKernelInterface, TerminableInterface { public int $terminateCalls = 0; @@ -256,7 +294,7 @@ public function __construct(private readonly Events $events) public function send(bool $flush = true): static { - $this->events->values[] = 'send'; + $this->events->values[] = 'send:' . ($flush ? 'true' : 'false'); return $this; } From 8f0d15adb01e15fa988383a55581a288e3b1ce80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Mon, 21 Sep 2026 15:06:12 +0300 Subject: [PATCH 03/42] feat: support Rapira Dispatcher mode --- .github/workflows/testing.yml | 12 +- README.md | 64 ++---- composer.json | 6 +- src/DispatcherRunner.php | 62 +++++ src/Internal/DispatcherRequest.php | 34 +++ src/Internal/DispatcherRequestFactory.php | 144 ++++++++++++ src/Internal/ExchangeResponseEmitter.php | 116 ++++++++++ src/Internal/RequestExchange.php | 76 ++++++ src/Runtime.php | 31 ++- src/{Runner.php => WorkerRunner.php} | 2 +- testo.php | 5 +- tests/Support/RuntimeForMode.php | 7 + tests/Support/StubExchange.php | 62 +++++ tests/Support/StubHttpDispatcher.php | 33 +++ tests/Unit/ARuntimeTest.php | 41 ++-- tests/Unit/DispatcherRequestFactoryTest.php | 150 ++++++++++++ tests/Unit/DispatcherRunnerTest.php | 217 ++++++++++++++++++ tests/Unit/ExchangeResponseEmitterTest.php | 158 +++++++++++++ .../{RunnerTest.php => WorkerRunnerTest.php} | 12 +- 19 files changed, 1157 insertions(+), 75 deletions(-) create mode 100644 src/DispatcherRunner.php create mode 100644 src/Internal/DispatcherRequest.php create mode 100644 src/Internal/DispatcherRequestFactory.php create mode 100644 src/Internal/ExchangeResponseEmitter.php create mode 100644 src/Internal/RequestExchange.php rename src/{Runner.php => WorkerRunner.php} (96%) create mode 100644 tests/Support/StubExchange.php create mode 100644 tests/Support/StubHttpDispatcher.php create mode 100644 tests/Unit/DispatcherRequestFactoryTest.php create mode 100644 tests/Unit/DispatcherRunnerTest.php create mode 100644 tests/Unit/ExchangeResponseEmitterTest.php rename tests/Unit/{RunnerTest.php => WorkerRunnerTest.php} (96%) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6eb5ec8..e260ef1 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,9 +17,13 @@ jobs: strategy: fail-fast: false matrix: - php-version: - - '8.4' - - '8.5' + include: + - php-version: '8.4' + dependencies: lowest + - php-version: '8.4' + dependencies: highest + - php-version: '8.5' + dependencies: highest steps: - name: Check out the codebase uses: actions/checkout@v6 @@ -36,6 +40,8 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} - name: Run unit tests run: composer test diff --git a/README.md b/README.md index 2a21636..884e3dd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # rapira/symfony -Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker mode. -It boots one Symfony kernel and uses it for sequential requests handled by a -resident Rapira process. The standard Symfony `public/index.php` stays unchanged. +Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker and Dispatcher modes. It boots one Symfony kernel and uses it for sequential requests handled by a resident Rapira process. Classic mode delegates to Symfony. The standard Symfony `public/index.php` stays unchanged in every mode. ## Installation @@ -16,9 +14,7 @@ The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. ## Application setup -This package provides a Rapira-aware Symfony Runtime and runtime autoload -template. Add the following project-level Symfony Runtime configuration to the -application's `composer.json`: +Add the Rapira-aware Runtime and autoload template to the application's `composer.json`: ```json { @@ -31,12 +27,7 @@ application's `composer.json`: } ``` -Run `composer dump-autoload` after adding it. Composer then generates -`vendor/autoload_runtime.php` with the Runtime class and conventional -`public/index.php` entrypoint baked in. This is important because Rapira exposes -`SCRIPT_FILENAME` only while handling a request, after the worker has already -booted. No `APP_RUNTIME` environment variable or custom bootstrap script is -required. Keep the normal Symfony front controller: +Run `composer dump-autoload`. Composer generates `vendor/autoload_runtime.php` with the Runtime class and conventional `public/index.php` entrypoint baked in. No `APP_RUNTIME` variable, separate worker script, or custom bootstrap is required: ```php terminate()` and are removed after termination. Files moved by application code remain at their destination. + +Responses are prepared by HttpFoundation before direct emission. Repeated headers and every `Set-Cookie` value are preserved. Ordinary responses finalize exactly once; streamed and binary responses progressively forward output to Rapira and then send end-of-stream. HEAD, 204, 304, file ranges, 416 responses, temporary files, and `deleteFileAfterSend()` follow Symfony's public `BinaryFileResponse` behavior. Dispatcher mode intentionally does not use `Response::send()`, fibers, or Rapira's zero-copy `sendFile()` yet. ## Persistent state and resets -Worker mode keeps the PHP process, kernel, container, and loaded services alive -between requests. Avoid storing request-specific state in static properties, -singletons, or long-lived services. Symfony's kernel runs its normal next-handle -service reset lifecycle, so services tagged for reset continue to work without -special Rapira integration. Application state outside that lifecycle must be -reset explicitly. The runtime also collects cyclic garbage after each successful -request lifecycle. - -Responses use Symfony HttpFoundation's normal `Response::send()` lifecycle. The -Rapira SAPI finalizes the response after the request callback returns; Symfony's -`terminate()` hook then runs before the next request is accepted. +Worker and Dispatcher modes keep the PHP process, kernel, container, and loaded services alive between requests. Avoid request-specific state in static properties, singletons, or long-lived services. Symfony's next-handle reset lifecycle still applies to services tagged for reset; other state must be reset by the application. + +For each successful Dispatcher request the order is handle, response finalization, `kernel->terminate()`, upload cleanup, then cyclic garbage collection. Worker mode finalizes through the SAPI callback before termination. Both modes accept the next request only after the previous lifecycle completes. diff --git a/composer.json b/composer.json index 18b7f38..810f5a5 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,10 @@ { "name": "rapira/symfony", - "description": "Symfony Runtime for Rapira Worker mode", + "description": "Symfony Runtime for Rapira Worker and Dispatcher modes", "license": "BSD-3-Clause", "type": "library", "keywords": [ + "dispatcher", "rapira", "runtime", "symfony", @@ -18,9 +19,12 @@ ], "require": { "php": ">=8.4", + "nyholm/psr7": "^1.8", "rapira/contract": "dev-master", + "rapira/http": "^0.1", "symfony/http-foundation": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/psr-http-message-bridge": "^7.4 || ^8.0", "symfony/runtime": "^7.4 || ^8.0" }, "require-dev": { diff --git a/src/DispatcherRunner.php b/src/DispatcherRunner.php new file mode 100644 index 0000000..1b8fbc4 --- /dev/null +++ b/src/DispatcherRunner.php @@ -0,0 +1,62 @@ +dispatcher->receive(); + } catch (ClosedException) { + return 0; + } + + $converted = null; + $request = null; + $response = null; + + try { + $converted = $this->requestFactory->create($exchange); + $request = $converted->request; + $response = $this->kernel->handle($request); + $this->responseEmitter->emit($exchange, $request, $response); + + if ($this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($request, $response); + } + } catch (WorkDiscardedException) { + } finally { + $response = null; + $request = null; + $converted?->cleanup(); + $converted = null; + $exchange = null; + \gc_collect_cycles(); + } + } + } +} diff --git a/src/Internal/DispatcherRequest.php b/src/Internal/DispatcherRequest.php new file mode 100644 index 0000000..fbcf05f --- /dev/null +++ b/src/Internal/DispatcherRequest.php @@ -0,0 +1,34 @@ + $uploadPaths + */ + public function __construct( + public readonly Request $request, + private array $uploadPaths, + ) {} + + public function cleanup(): void + { + foreach ($this->uploadPaths as $path) { + if (\is_file($path)) { + @\unlink($path); + } + } + + $this->uploadPaths = []; + } +} diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php new file mode 100644 index 0000000..035c728 --- /dev/null +++ b/src/Internal/DispatcherRequestFactory.php @@ -0,0 +1,144 @@ + */ + private array $server; + + private PsrRequestFactory $psrFactory; + private HttpFoundationFactory $httpFoundationFactory; + + /** + * @param array|null $server + */ + public function __construct(?array $server = null) + { + $this->server = \array_filter( + $server ?? $_SERVER, + static fn(mixed $key): bool => !\str_starts_with((string) $key, 'HTTP_'), + \ARRAY_FILTER_USE_KEY, + ); + + $psr17Factory = new Psr17Factory(); + $this->psrFactory = new PsrRequestFactory($psr17Factory, $psr17Factory, $psr17Factory); + $this->httpFoundationFactory = new HttpFoundationFactory(); + } + + public function create(Exchange $exchange): DispatcherRequest + { + $source = $exchange->getRequest(); + $psrRequest = $this->psrFactory->create(new RequestExchange( + $exchange, + $this->normalizeRequest($source), + )); + $psrRequest = $psrRequest->withCookieParams($this->parseCookies($source->headers)); + + $request = $this->httpFoundationFactory->createRequest($psrRequest); + $request->server->add($this->server); + $request->server->set('REQUEST_URI', $source->target); + + return new DispatcherRequest($request, $this->uploadPaths($request)); + } + + private function normalizeRequest(RapiraRequest $request): RapiraRequest + { + /** @var array> $headers */ + $headers = []; + /** @var array $names */ + $names = []; + foreach ($request->headers as $name => $values) { + $lower = \strtolower($name); + if (isset($names[$lower])) { + $headers[$names[$lower]] = [...$headers[$names[$lower]], ...$values]; + continue; + } + + $names[$lower] = $name; + $headers[$name] = $values; + } + + return new RapiraRequest( + $request->method, + $request->uri, + $request->target, + $request->authority, + $request->protocol, + $headers, + $request->body, + $request->remote, + $request->server, + $request->tls, + $request->receivedAt, + ); + } + + /** + * @psalm-pure + * + * @param array> $headers + * + * @return array + */ + private function parseCookies(array $headers): array + { + $cookies = []; + foreach ($headers as $name => $values) { + if (\strcasecmp($name, 'cookie') !== 0) { + continue; + } + + foreach ($values as $value) { + foreach (\explode(';', $value) as $pair) { + $parts = \explode('=', $pair, 2); + if (\count($parts) === 2) { + $cookies[\trim($parts[0])] = \trim($parts[1]); + } + } + } + } + + return $cookies; + } + + /** + * @return list + */ + private function uploadPaths(Request $request): array + { + $paths = []; + $files = $request->files->all(); + /** @psalm-suppress MixedAssignment */ + $walk = static function (array $files) use (&$walk, &$paths): void { + foreach ($files as $file) { + if ($file instanceof UploadedFile) { + if ($file->getError() !== \UPLOAD_ERR_NO_FILE) { + $paths[] = $file->getPathname(); + } + continue; + } + + if (\is_array($file)) { + $walk($file); + } + } + }; + $walk($files); + + return $paths; + } +} diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php new file mode 100644 index 0000000..1613974 --- /dev/null +++ b/src/Internal/ExchangeResponseEmitter.php @@ -0,0 +1,116 @@ +prepare($request); + if ( + $request->isMethod('HEAD') + || $response->isInformational() + || $response->isEmpty() + || ($response->getContent() === false && !$response->isSuccessful()) + ) { + $response->headers->remove('Content-Length'); + } + + $this->checkCancellation($exchange); + /** @var int<100, 599> $status */ + $status = $response->getStatusCode(); + $exchange->writeHead($status, $this->headers($response)); + + if ($request->isMethod('HEAD') || $response->isInformational() || $response->isEmpty()) { + $this->checkCancellation($exchange); + $exchange->writeBody(''); + + return; + } + + $content = $response->getContent(); + if ($content !== false) { + $this->checkCancellation($exchange); + $exchange->writeBody($content); + + return; + } + + $this->stream($exchange, $response); + } + + /** + * @return array> + */ + private function headers(Response $response): array + { + /** @var array> $headers */ + $headers = $response->headers->allPreserveCaseWithoutCookies(); + $cookies = $response->headers->getCookies(); + if ($cookies !== []) { + $headers['Set-Cookie'] = \array_values(\array_map( + static fn(object $cookie): string => (string) $cookie, + $cookies, + )); + } + + return $headers; + } + + private function stream(Exchange $exchange, Response $response): void + { + $level = \ob_get_level(); + $failure = null; + + \ob_start(function (string $chunk) use ($exchange): string { + if ($chunk !== '') { + $this->checkCancellation($exchange); + $exchange->writeBody($chunk, false); + } + + return ''; + }, 1); + + try { + $response->sendContent(); + if (\ob_get_level() <= $level) { + throw new \LogicException('The response removed the Dispatcher output buffer.'); + } + + while (\ob_get_level() > $level + 1) { + \ob_end_flush(); + } + \ob_end_flush(); + } catch (\Throwable $exception) { + $failure = $exception; + } finally { + while (\ob_get_level() > $level) { + \ob_end_clean(); + } + } + + if ($failure !== null) { + throw $failure; + } + + $this->checkCancellation($exchange); + $exchange->writeBody(''); + } + + private function checkCancellation(Exchange $exchange): void + { + if ($exchange->isCancelled()) { + throw new WorkDiscardedException('The HTTP exchange was cancelled.'); + } + } +} diff --git a/src/Internal/RequestExchange.php b/src/Internal/RequestExchange.php new file mode 100644 index 0000000..b222500 --- /dev/null +++ b/src/Internal/RequestExchange.php @@ -0,0 +1,76 @@ +request; + } + + #[\Override] + public function isFinalized(): bool + { + return $this->exchange->isFinalized(); + } + + #[\Override] + public function isCancelled(): bool + { + return $this->exchange->isCancelled(); + } + + #[\Override] + public function writeHead(int $status, array $headers = []): void + { + $this->exchange->writeHead($status, $headers); + } + + #[\Override] + public function writeBody(string $content, bool $eos = true): void + { + $this->exchange->writeBody($content, $eos); + } + + #[\Override] + public function sendFile(string $path, int $offset = 0, ?int $length = null, bool $eos = true): void + { + $this->exchange->sendFile($path, $offset, $length, $eos); + } + + #[\Override] + public function writeTrailers(array $trailers): void + { + $this->exchange->writeTrailers($trailers); + } + + #[\Override] + public function flush(): void + { + $this->exchange->flush(); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function __destruct() {} +} diff --git a/src/Runtime.php b/src/Runtime.php index 1fa0d72..d7bb4c6 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -4,7 +4,10 @@ namespace Rapira\Symfony; +use Rapira\Http\HttpDispatcher; use Rapira\Mode; +use Rapira\Symfony\Internal\DispatcherRequestFactory; +use Rapira\Symfony\Internal\ExchangeResponseEmitter; use Rapira\Symfony\Internal\NativeRequestLoop; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Runtime\RunnerInterface; @@ -42,7 +45,7 @@ public function __construct(array $options = []) { $this->mode = $this->getMode(); - if ($this->mode === Mode::Worker) { + if ($this->mode === Mode::Worker || $this->mode === Mode::Dispatcher) { $_SERVER['APP_RUNTIME_MODE'] = 'web=1&worker=1'; } @@ -58,9 +61,12 @@ public function getRunner(?object $application): RunnerInterface return match ($this->mode) { Mode::Classic => parent::getRunner($application), - Mode::Worker => new Runner($application, new NativeRequestLoop()), - Mode::Dispatcher => throw new \LogicException( - 'Rapira Dispatcher mode is not supported for Symfony HttpKernel applications.', + Mode::Worker => new WorkerRunner($application, new NativeRequestLoop()), + Mode::Dispatcher => new DispatcherRunner( + $application, + $this->getHttpDispatcher(), + new DispatcherRequestFactory(), + new ExchangeResponseEmitter(), ), }; } @@ -74,4 +80,21 @@ protected function getMode(): Mode { return \Rapira\get_mode(); } + + /** + * @psalm-suppress MissingPureAnnotation + * @psalm-suppress UndefinedFunction The function is provided by the Rapira extension and its contract. + */ + protected function getHttpDispatcher(): HttpDispatcher + { + $dispatcher = \Rapira\get_dispatcher(); + if (!$dispatcher instanceof HttpDispatcher) { + throw new \LogicException(\sprintf( + 'Rapira Dispatcher mode requires an HTTP dispatcher; got %s.', + $dispatcher::class, + )); + } + + return $dispatcher; + } } diff --git a/src/Runner.php b/src/WorkerRunner.php similarity index 96% rename from src/Runner.php rename to src/WorkerRunner.php index 4082d2d..ca2eb14 100644 --- a/src/Runner.php +++ b/src/WorkerRunner.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; -final readonly class Runner implements RunnerInterface +final readonly class WorkerRunner implements RunnerInterface { /** * @psalm-mutation-free diff --git a/testo.php b/testo.php index d175ef7..886ab86 100644 --- a/testo.php +++ b/testo.php @@ -11,10 +11,7 @@ suites: [ new SuiteConfig( name: 'Unit', - location: new FinderConfig(include: [ - __DIR__ . '/tests/Unit/ARuntimeTest.php', - __DIR__ . '/tests/Unit/RunnerTest.php', - ]), + location: new FinderConfig([__DIR__ . '/tests/Unit']), ), ], ); diff --git a/tests/Support/RuntimeForMode.php b/tests/Support/RuntimeForMode.php index dbc3b7e..bd1faad 100644 --- a/tests/Support/RuntimeForMode.php +++ b/tests/Support/RuntimeForMode.php @@ -4,6 +4,7 @@ namespace Rapira\Symfony\Tests\Support; +use Rapira\Http\HttpDispatcher; use Rapira\Mode; use Rapira\Symfony\Runtime; @@ -12,6 +13,7 @@ final class RuntimeForMode extends Runtime public function __construct( private readonly Mode $runtimeMode, array $options = [], + private readonly ?HttpDispatcher $httpDispatcher = null, ) { parent::__construct($options); } @@ -20,4 +22,9 @@ protected function getMode(): Mode { return $this->runtimeMode; } + + protected function getHttpDispatcher(): HttpDispatcher + { + return $this->httpDispatcher ?? parent::getHttpDispatcher(); + } } diff --git a/tests/Support/StubExchange.php b/tests/Support/StubExchange.php new file mode 100644 index 0000000..cfc3316 --- /dev/null +++ b/tests/Support/StubExchange.php @@ -0,0 +1,62 @@ +>}> */ + public array $heads = []; + + /** @var list */ + public array $bodies = []; + + public bool $cancelled = false; + public bool $finalized = false; + + public function __construct(private readonly Request $request) {} + + public function getRequest(): Request + { + return $this->request; + } + + public function isFinalized(): bool + { + return $this->finalized; + } + + public function isCancelled(): bool + { + return $this->cancelled; + } + + public function writeHead(int $status, array $headers = []): void + { + $this->heads[] = ['status' => $status, 'headers' => $headers]; + } + + public function writeBody(string $content, bool $eos = true): void + { + $this->bodies[] = ['content' => $content, 'eos' => $eos]; + $this->finalized = $eos; + } + + public function sendFile(string $path, int $offset = 0, ?int $length = null, bool $eos = true): void + { + throw new \BadMethodCallException(); + } + + public function writeTrailers(array $trailers): void + { + throw new \BadMethodCallException(); + } + + public function flush(): void {} + + public function __destruct() {} +} diff --git a/tests/Support/StubHttpDispatcher.php b/tests/Support/StubHttpDispatcher.php new file mode 100644 index 0000000..7582286 --- /dev/null +++ b/tests/Support/StubHttpDispatcher.php @@ -0,0 +1,33 @@ + false, 'error_handler' => false]); Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); - Assert::true($runtime->getRunner(new NullKernel()) instanceof Runner); + Assert::true($runtime->getRunner(new NullKernel()) instanceof WorkerRunner); } - public function classicModeDelegatesToSymfonyRuntime(): void + public function dispatcherModeSelectsDispatcherRunnerAndSetsRuntimeModeBeforeSymfonyInitialization(): void { unset($_SERVER['APP_RUNTIME_MODE']); - $runtime = new RuntimeForMode(Mode::Classic, ['debug' => false, 'error_handler' => false]); + $runtime = new RuntimeForMode( + Mode::Dispatcher, + ['debug' => false, 'error_handler' => false], + new StubHttpDispatcher(), + ); - Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); - Assert::true($runtime->getRunner(new NullKernel()) instanceof HttpKernelRunner); + Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); + Assert::true($runtime->getRunner(new NullKernel()) instanceof DispatcherRunner); } - public function nonHttpApplicationsDelegateInWorkerMode(): void + public function classicModeDelegatesToSymfonyRuntime(): void { - $runtime = new RuntimeForMode(Mode::Worker, ['debug' => false, 'error_handler' => false]); + unset($_SERVER['APP_RUNTIME_MODE']); + + $runtime = new RuntimeForMode(Mode::Classic, ['debug' => false, 'error_handler' => false]); - Assert::same($runtime->getRunner(static fn(): int => 17)->run(), 17); + Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); + Assert::true($runtime->getRunner(new NullKernel()) instanceof HttpKernelRunner); } - public function dispatcherModeRejectsHttpKernelApplications(): void + public function nonHttpApplicationsDelegateInResidentModes(): void { - $runtime = new RuntimeForMode(Mode::Dispatcher, ['debug' => false, 'error_handler' => false]); - - Expect::exception(\LogicException::class) - ->withMessage('Rapira Dispatcher mode is not supported for Symfony HttpKernel applications.'); - $runtime->getRunner(new NullKernel()); + foreach ([Mode::Worker, Mode::Dispatcher] as $mode) { + $runtime = new RuntimeForMode($mode, ['debug' => false, 'error_handler' => false]); + Assert::same($runtime->getRunner(static fn(): int => 17)->run(), 17); + } } } diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Unit/DispatcherRequestFactoryTest.php new file mode 100644 index 0000000..9f816af --- /dev/null +++ b/tests/Unit/DispatcherRequestFactoryTest.php @@ -0,0 +1,150 @@ + 'test', 'HTTP_BOOT_ONLY' => 'no']; + $_GET = ['global' => 'unchanged']; + $_POST = ['global' => 'unchanged']; + $_COOKIE = ['global' => 'unchanged']; + $_FILES = ['global' => 'unchanged']; + $globals = [$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES]; + + $factory = new DispatcherRequestFactory($_SERVER); + $converted = $factory->create(new StubExchange(self::request( + method: 'POST', + uri: 'https://example.test/path?q=one&q=two', + target: '/path?q=one&q=two&raw=%2F', + protocol: 'HTTP/2', + headers: [ + 'Host' => ['example.test'], + 'X-Repeat' => ['one'], + 'x-repeat' => ['two'], + 'Cookie' => ['a=1'], + 'cookie' => ['b=2'], + 'Content-Type' => ['application/json'], + ], + body: "{\"binary\":\"\\u0000\"}", + ))); + $request = $converted->request; + + Assert::same($request->getMethod(), 'POST'); + Assert::same($request->server->get('REQUEST_URI'), '/path?q=one&q=two&raw=%2F'); + Assert::same($request->server->get('SERVER_PROTOCOL'), 'HTTP/2'); + Assert::same($request->headers->all('x-repeat'), ['one', 'two']); + Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); + Assert::same($request->getContent(), "{\"binary\":\"\\u0000\"}"); + Assert::same($request->server->get('APP_ENV'), 'test'); + Assert::null($request->headers->get('boot-only')); + Assert::same([$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES], $globals); + } + + public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies(): void + { + $form = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request( + method: 'POST', + headers: ['Content-Type' => ['application/x-www-form-urlencoded']], + body: 'user[name]=Ada&items[]=one&items[]=two', + )))->request; + Assert::same($form->request->all(), [ + 'user' => ['name' => 'Ada'], + 'items' => ['one', 'two'], + ]); + + $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); + \file_put_contents($source, 'upload-body'); + $multipart = new Multipart( + [new FormField('meta[name]', 'Ada', [])], + [ + new RapiraUploadedFile('files[docs][]', 'note.txt', 'text/plain', [], $source, 11), + new RapiraUploadedFile('empty', '', null, [], $source, 0), + ], + ); + $converted = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request( + method: 'POST', + body: $multipart, + ))); + $request = $converted->request; + + Assert::same($request->request->all(), ['meta' => ['name' => 'Ada']]); + $file = $request->files->all()['files']['docs'][0]; + Assert::true($file instanceof UploadedFile); + Assert::same($file->getClientOriginalName(), 'note.txt'); + Assert::same(\file_get_contents($file->getPathname()), 'upload-body'); + Assert::same($request->files->all()['empty']->getError(), \UPLOAD_ERR_NO_FILE); + $copy = $file->getPathname(); + Assert::true(\is_file($copy)); + + $converted->cleanup(); + + Assert::false(\is_file($copy)); + Assert::true(\is_file($source)); + @\unlink($source); + } + + public function leavesMovedUploadAliveAfterCleanup(): void + { + $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); + \file_put_contents($source, 'keep'); + $multipart = new Multipart([], [ + new RapiraUploadedFile('file', 'file.txt', 'text/plain', [], $source, 4), + ]); + $converted = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request(body: $multipart))); + $destination = \tempnam(\sys_get_temp_dir(), 'rapira-moved-'); + @\unlink($destination); + $converted->request->files->get('file')->move(\dirname($destination), \basename($destination)); + + $converted->cleanup(); + + Assert::same(\file_get_contents($destination), 'keep'); + @\unlink($destination); + @\unlink($source); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $uri + * @param non-empty-string $target + * @param non-empty-string $protocol + * @param array> $headers + */ + private static function request( + string $method = 'GET', + string $uri = 'http://localhost/', + string $target = '/', + string $protocol = 'HTTP/1.1', + array $headers = [], + string|Multipart $body = '', + ): RapiraRequest { + return new RapiraRequest( + $method, + $uri, + $target, + null, + $protocol, + $headers, + $body, + new InetAddress('127.0.0.1', 40000), + new InetAddress('127.0.0.1', 8080), + null, + 1_700_000_000.5, + ); + } +} diff --git a/tests/Unit/DispatcherRunnerTest.php b/tests/Unit/DispatcherRunnerTest.php new file mode 100644 index 0000000..d00e082 --- /dev/null +++ b/tests/Unit/DispatcherRunnerTest.php @@ -0,0 +1,217 @@ +exchange('/one'), + $this->exchange('/two'), + ], $events); + $kernel = new LifecycleKernel($events); + + $result = $this->runner($kernel, $dispatcher)->run(); + + Assert::same($result, 0); + Assert::same($kernel->paths, ['/one', '/two']); + Assert::false($kernel->requestIds[0] === $kernel->requestIds[1]); + Assert::same($events, [ + 'receive', 'handle:/one', 'terminate:/one', + 'receive', 'handle:/two', 'terminate:/two', + 'receive', 'closed', + ]); + } + + public function cancellationContinuesToNextExchangeWithoutTerminatingCancelledRequest(): void + { + $events = []; + $cancelled = $this->exchange('/cancelled'); + $cancelled->cancelled = true; + $healthy = $this->exchange('/healthy'); + $dispatcher = new QueueHttpDispatcher([$cancelled, $healthy], $events); + $kernel = new LifecycleKernel($events); + + Assert::same($this->runner($kernel, $dispatcher)->run(), 0); + Assert::same($kernel->paths, ['/cancelled', '/healthy']); + Assert::same($kernel->terminated, ['/healthy']); + Assert::same($cancelled->heads, []); + Assert::true($healthy->finalized); + } + + public function discardedWriteContinuesButUnexpectedKernelErrorsPropagate(): void + { + $events = []; + $discarded = new DiscardingExchange($this->request('/discarded')); + $healthy = $this->exchange('/healthy'); + $dispatcher = new QueueHttpDispatcher([$discarded, $healthy], $events); + $kernel = new LifecycleKernel($events); + Assert::same($this->runner($kernel, $dispatcher)->run(), 0); + Assert::true($healthy->finalized); + Assert::same($kernel->terminated, ['/healthy']); + + $events = []; + $dispatcher = new QueueHttpDispatcher([$this->exchange('/fail'), $this->exchange('/must-not-run')], $events); + $kernel = new ThrowingDispatcherKernel($events); + Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); + try { + $this->runner($kernel, $dispatcher)->run(); + } finally { + Assert::same($dispatcher->receiveCalls, 1); + } + } + + private function runner(HttpKernelInterface $kernel, HttpDispatcher $dispatcher): DispatcherRunner + { + return new DispatcherRunner( + $kernel, + $dispatcher, + new DispatcherRequestFactory([]), + new ExchangeResponseEmitter(), + ); + } + + private function exchange(string $path): StubExchange + { + return new StubExchange($this->request($path)); + } + + private function request(string $path): RapiraRequest + { + return new RapiraRequest( + 'GET', + 'http://localhost' . $path, + $path, + 'localhost', + 'HTTP/1.1', + [], + '', + new InetAddress('127.0.0.1', 40000), + new InetAddress('127.0.0.1', 8080), + null, + 0.0, + ); + } +} + +final class QueueHttpDispatcher implements HttpDispatcher +{ + public int $receiveCalls = 0; + + /** + * @param list $exchanges + * @param list $events + */ + public function __construct( + private array $exchanges, + private array &$events, + ) {} + + public function name(): string + { + return 'http'; + } + + public function tryReceive(): ?Exchange + { + return \array_shift($this->exchanges); + } + + public function receive(int $timeout = -1): Exchange + { + ++$this->receiveCalls; + $this->events[] = 'receive'; + $exchange = \array_shift($this->exchanges); + if ($exchange === null) { + $this->events[] = 'closed'; + throw new ClosedException(); + } + + return $exchange; + } + + public function getInfo(): HttpDispatcherInfo + { + throw new \BadMethodCallException(); + } +} + +final class LifecycleKernel implements HttpKernelInterface, TerminableInterface +{ + /** @var list */ + public array $paths = []; + + /** @var list */ + public array $requestIds = []; + + /** @var list */ + public array $terminated = []; + + /** + * @param list $events + */ + public function __construct(private array &$events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $path = $request->getPathInfo(); + $this->paths[] = $path; + $this->requestIds[] = \spl_object_id($request); + $this->events[] = 'handle:' . $path; + + return new Response($path); + } + + public function terminate(Request $request, Response $response): void + { + $path = $request->getPathInfo(); + $this->terminated[] = $path; + $this->events[] = 'terminate:' . $path; + } +} + +final class ThrowingDispatcherKernel implements HttpKernelInterface +{ + /** + * @param list $events + */ + public function __construct(private array &$events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->events[] = 'handle:' . $request->getPathInfo(); + throw new \RuntimeException('kernel failed'); + } +} + +final class DiscardingExchange extends StubExchange +{ + public function writeHead(int $status, array $headers = []): void + { + throw new WorkDiscardedException(); + } +} diff --git a/tests/Unit/ExchangeResponseEmitterTest.php b/tests/Unit/ExchangeResponseEmitterTest.php new file mode 100644 index 0000000..e521964 --- /dev/null +++ b/tests/Unit/ExchangeResponseEmitterTest.php @@ -0,0 +1,158 @@ +exchange(); + $response = new Response('body', 201, ['X-Repeat' => ['one', 'two']]); + $response->headers->setCookie(new Cookie('a', 'one')); + $response->headers->setCookie(new Cookie('b', 'two')); + + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); + + Assert::same($exchange->heads[0]['status'], 201); + Assert::same($exchange->heads[0]['headers']['X-Repeat'], ['one', 'two']); + Assert::count($exchange->heads[0]['headers']['Set-Cookie'], 2); + Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); + } + + public function suppressesPreparedHeadAndEmptyResponseBodies(): void + { + foreach ([ + [Request::create('/', 'HEAD'), new Response('must-not-run')], + [Request::create('/'), new Response('must-not-run', 204)], + [Request::create('/'), new Response('must-not-run', 304)], + ] as [$request, $response]) { + $exchange = $this->exchange($request->getMethod()); + (new ExchangeResponseEmitter())->emit($exchange, $request, $response); + Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); + Assert::false(isset($exchange->heads[0]['headers']['Content-Length'])); + } + } + + public function streamsProgressiveChunksAndRestoresOuterBuffer(): void + { + $exchange = $this->exchange(); + $response = new StreamedResponse(static function (): void { + echo 'one'; + \ob_flush(); + echo 'two'; + }); + $level = \ob_get_level(); + \ob_start(); + try { + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); + Assert::same(\ob_get_level(), $level + 1); + } finally { + \ob_end_clean(); + } + + Assert::same($exchange->bodies, [ + ['content' => 'one', 'eos' => false], + ['content' => 'two', 'eos' => false], + ['content' => '', 'eos' => true], + ]); + } + + public function restoresBuffersAndPropagatesStreamFailure(): void + { + $exchange = $this->exchange(); + $response = new StreamedResponse(static function (): void { + echo 'before'; + throw new \RuntimeException('stream failed'); + }); + $level = \ob_get_level(); + + Expect::exception(\RuntimeException::class)->withMessage('stream failed'); + try { + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); + } finally { + Assert::same(\ob_get_level(), $level); + Assert::false($exchange->finalized); + } + } + + public function cancellationStopsBeforeCommit(): void + { + $exchange = $this->exchange(); + $exchange->cancelled = true; + + Expect::exception(WorkDiscardedException::class); + try { + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('body')); + } finally { + Assert::same($exchange->heads, []); + Assert::same($exchange->bodies, []); + } + } + + public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void + { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); + \file_put_contents($path, '0123456789'); + + $exchange = $this->exchange(); + $request = Request::create('/file', 'GET', server: ['HTTP_RANGE' => 'bytes=2-5']); + $response = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); + $response->setChunkSize(2); + (new ExchangeResponseEmitter())->emit($exchange, $request, $response); + + Assert::same($exchange->heads[0]['status'], 206); + Assert::same($exchange->heads[0]['headers']['Content-Range'], ['bytes 2-5/10']); + Assert::same(\implode('', \array_column($exchange->bodies, 'content')), '2345'); + Assert::true($exchange->finalized); + + $unsatisfiable = $this->exchange(); + $unsatisfiableRequest = Request::create('/file', 'GET', server: ['HTTP_RANGE' => 'bytes=999-1000']); + $unsatisfiableResponse = new BinaryFileResponse( + $path, + headers: ['Content-Type' => 'application/octet-stream'], + ); + (new ExchangeResponseEmitter())->emit($unsatisfiable, $unsatisfiableRequest, $unsatisfiableResponse); + Assert::same($unsatisfiable->heads[0]['status'], 416); + Assert::false(isset($unsatisfiable->heads[0]['headers']['Content-Length'])); + Assert::same($unsatisfiable->bodies, [['content' => '', 'eos' => true]]); + + $delete = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); + $delete->deleteFileAfterSend(); + (new ExchangeResponseEmitter())->emit($this->exchange(), Request::create('/file'), $delete); + Assert::false(\is_file($path)); + } + + private function exchange(string $method = 'GET'): StubExchange + { + return new StubExchange(new RapiraRequest( + $method, + 'http://localhost/', + '/', + 'localhost', + 'HTTP/1.1', + [], + '', + new InetAddress('127.0.0.1', 40000), + new InetAddress('127.0.0.1', 8080), + null, + 0.0, + )); + } +} diff --git a/tests/Unit/RunnerTest.php b/tests/Unit/WorkerRunnerTest.php similarity index 96% rename from tests/Unit/RunnerTest.php rename to tests/Unit/WorkerRunnerTest.php index 296de6c..45f0e86 100644 --- a/tests/Unit/RunnerTest.php +++ b/tests/Unit/WorkerRunnerTest.php @@ -5,7 +5,7 @@ namespace Rapira\Symfony\Tests\Unit; use Rapira\Symfony\Internal\RequestLoop; -use Rapira\Symfony\Runner; +use Rapira\Symfony\WorkerRunner; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -15,7 +15,7 @@ use Testo\Test; #[Test] -final class RunnerTest +final class WorkerRunnerTest { public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): void { @@ -54,7 +54,7 @@ public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): ]); \ob_start(); - $result = (new Runner($kernel, $loop))->run(); + $result = (new WorkerRunner($kernel, $loop))->run(); $output = (string) \ob_get_clean(); Assert::same($result, 0); @@ -97,7 +97,7 @@ public function sendsThenTerminatesExactlyOncePerRequestAndDrains(): void $loop = new FakeRequestLoop([$this->requestGlobals('/lifecycle')], $events); \ob_start(); - $result = (new Runner($kernel, $loop))->run(); + $result = (new WorkerRunner($kernel, $loop))->run(); \ob_end_clean(); Assert::same($result, 0); @@ -117,7 +117,7 @@ public function terminateExceptionsPropagateBeforeCollectionAndTheNextRequest(): Expect::exception(\RuntimeException::class)->withMessage('terminate failed'); try { - (new Runner($kernel, $loop))->run(); + (new WorkerRunner($kernel, $loop))->run(); } finally { Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate']); Assert::same($loop->handlers, 1); @@ -136,7 +136,7 @@ public function propagatesExceptionsWithoutTerminatingOrContinuing(): void Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); try { - (new Runner($kernel, $loop))->run(); + (new WorkerRunner($kernel, $loop))->run(); } finally { Assert::same($events->values, ['loop:before', 'handle']); Assert::same($kernel->terminateCalls, 0); From f7be4ac1d1c8b8b888edc8fe03ad08b0bde60d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Mon, 21 Sep 2026 16:00:36 +0300 Subject: [PATCH 04/42] fix: harden Rapira Dispatcher lifecycle --- README.md | 6 +- src/DispatcherRunner.php | 16 +- src/Internal/DispatcherRequestFactory.php | 4 +- src/Internal/ExchangeResponseEmitter.php | 132 ++++++++++++--- src/Internal/RequestLoop.php | 5 +- src/Internal/ResponseDiscardedException.php | 21 +++ src/WorkerRunner.php | 3 + tests/Support/StubExchange.php | 10 ++ tests/Unit/DispatcherRequestFactoryTest.php | 29 +++- tests/Unit/DispatcherRunnerTest.php | 34 +++- tests/Unit/ExchangeResponseEmitterTest.php | 179 +++++++++++++++++--- 11 files changed, 371 insertions(+), 68 deletions(-) create mode 100644 src/Internal/ResponseDiscardedException.php diff --git a/README.md b/README.md index 884e3dd..a0008dc 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,17 @@ Start Rapira with `rapira serve rapira.toml`. - **Worker** uses Rapira's SAPI request loop and Symfony's normal `Response::send()` lifecycle. - **Classic** delegates to Symfony's standard runtime behavior. -Rapira owns recycling and `max_requests`; this runtime does not apply a second request limit. Dispatcher drain through `ClosedException` exits cleanly. Client cancellation is checked around writes and discarded work does not poison the next request. Unexpected application, adapter, and transport errors propagate so Rapira can recycle the worker. +Rapira owns recycling and `max_requests`; this runtime does not apply a second request limit. Dispatcher drain through `ClosedException` exits cleanly. Work already cancelled before conversion is skipped. If cancellation happens while emitting a handled response, transport output stops but `kernel->terminate()` still runs; cancellation exceptions thrown by application or termination code are not swallowed. Unexpected application, adapter, termination, and transport programming errors propagate so Rapira can recycle the worker. ## Dispatcher semantics Dispatcher mode does not synthesize request superglobals. `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`, `php://input`, `header()`, native PHP sessions, and libraries that require SAPI request globals should not be used for request handling. Use the Symfony `Request`, response headers, and a Symfony-compatible session implementation. -The conversion preserves the exact request target in `REQUEST_URI`, protocol, repeated headers, cookies, body, parsed forms, nested multipart fields and uploads, TLS state, and network addresses. Non-HTTP values present in the boot-time server bag remain available without leaking boot-time `HTTP_*` values. +The conversion preserves the exact request target in `REQUEST_URI`, byte-exact raw method for transport decisions, protocol, repeated headers, cookies, body, parsed forms, nested multipart fields and uploads, TLS state, and network addresses. Live request metadata always wins over the non-HTTP boot-time server defaults, and boot-time `HTTP_*` values are excluded. Multipart uploads are copied to Symfony-owned temporary files. They remain readable through `kernel->terminate()` and are removed after termination. Files moved by application code remain at their destination. -Responses are prepared by HttpFoundation before direct emission. Repeated headers and every `Set-Cookie` value are preserved. Ordinary responses finalize exactly once; streamed and binary responses progressively forward output to Rapira and then send end-of-stream. HEAD, 204, 304, file ranges, 416 responses, temporary files, and `deleteFileAfterSend()` follow Symfony's public `BinaryFileResponse` behavior. Dispatcher mode intentionally does not use `Response::send()`, fibers, or Rapira's zero-copy `sendFile()` yet. +Responses are prepared by HttpFoundation before direct emission. Repeated headers and every `Set-Cookie` value are preserved. Ordinary responses finalize exactly once; `StreamedResponse`, streamed JSON, and binary responses progressively forward output to Rapira and then send end-of-stream. HEAD retains prepared metadata internally (including binary file length) while Rapira applies its own wire-level HEAD framing; 204, 304, file ranges, 416 responses, temporary files, and `deleteFileAfterSend()` follow Symfony's public behavior. Terminal 100–199 responses other than 101 are rejected because Rapira treats them as interim. Symfony's built-in `EventStreamResponse` is currently rejected because it removes output buffers it does not own; use `StreamedResponse` for server-sent events. Dispatcher mode intentionally does not use `Response::send()`, fibers, or Rapira's zero-copy `sendFile()` yet. ## Persistent state and resets diff --git a/src/DispatcherRunner.php b/src/DispatcherRunner.php index 1b8fbc4..62cf3f4 100644 --- a/src/DispatcherRunner.php +++ b/src/DispatcherRunner.php @@ -5,14 +5,17 @@ namespace Rapira\Symfony; use Rapira\Exception\ClosedException; -use Rapira\Exception\WorkDiscardedException; use Rapira\Http\HttpDispatcher; use Rapira\Symfony\Internal\DispatcherRequestFactory; use Rapira\Symfony\Internal\ExchangeResponseEmitter; +use Rapira\Symfony\Internal\ResponseDiscardedException; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; +/** + * @internal + */ final readonly class DispatcherRunner implements RunnerInterface { /** @@ -40,15 +43,22 @@ public function run(): int $response = null; try { + if ($exchange->isCancelled()) { + continue; + } + $converted = $this->requestFactory->create($exchange); $request = $converted->request; $response = $this->kernel->handle($request); - $this->responseEmitter->emit($exchange, $request, $response); + + try { + $this->responseEmitter->emit($exchange, $request, $response); + } catch (ResponseDiscardedException) { + } if ($this->kernel instanceof TerminableInterface) { $this->kernel->terminate($request, $response); } - } catch (WorkDiscardedException) { } finally { $response = null; $request = null; diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 035c728..8beed92 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -49,8 +49,10 @@ public function create(Exchange $exchange): DispatcherRequest $psrRequest = $psrRequest->withCookieParams($this->parseCookies($source->headers)); $request = $this->httpFoundationFactory->createRequest($psrRequest); - $request->server->add($this->server); + $request->server->replace($request->server->all() + $this->server); $request->server->set('REQUEST_URI', $source->target); + $request->server->set('RAPIRA_REQUEST_METHOD', $source->method); + $request->attributes->set('rapira.request_method', $source->method); return new DispatcherRequest($request, $this->uploadPaths($request)); } diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 1613974..59eea98 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -6,6 +6,8 @@ use Rapira\Exception\WorkDiscardedException; use Rapira\Http\Exchange; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\EventStreamResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,24 +18,44 @@ { public function emit(Exchange $exchange, Request $request, Response $response): void { - $response->prepare($request); - if ( - $request->isMethod('HEAD') - || $response->isInformational() - || $response->isEmpty() - || ($response->getContent() === false && !$response->isSuccessful()) - ) { + if ($response->getStatusCode() >= 100 && $response->getStatusCode() < 200 && $response->getStatusCode() !== 101) { + throw new \LogicException('A terminal Symfony response cannot use an interim HTTP status.'); + } + + if (\class_exists(EventStreamResponse::class) && $response instanceof EventStreamResponse) { + throw new \LogicException('Symfony EventStreamResponse is not supported in Rapira Dispatcher mode.'); + } + + $rawMethod = $request->attributes->getString('rapira.request_method'); + if ($rawMethod === '') { + $rawMethod = $request->server->getString('RAPIRA_REQUEST_METHOD', $request->server->getString('REQUEST_METHOD')); + } + $prepareRequest = $request; + if ($rawMethod === 'HEAD') { + $prepareRequest = clone $request; + $prepareRequest->setMethod('GET'); + } elseif ($request->isMethod('HEAD')) { + $prepareRequest = clone $request; + $prepareRequest->setMethod('RAPIRA_NONSTANDARD_HEAD'); + } + + $response->prepare($prepareRequest); + if ($response instanceof BinaryFileResponse && !$response->isSuccessful()) { $response->headers->remove('Content-Length'); } $this->checkCancellation($exchange); /** @var int<100, 599> $status */ $status = $response->getStatusCode(); - $exchange->writeHead($status, $this->headers($response)); + $this->writeHead($exchange, $status, $this->headers($response)); + + if ($rawMethod === 'HEAD' || $response->isEmpty()) { + if ($response instanceof BinaryFileResponse) { + $this->sendContent($exchange, $response, false); + } - if ($request->isMethod('HEAD') || $response->isInformational() || $response->isEmpty()) { $this->checkCancellation($exchange); - $exchange->writeBody(''); + $this->writeBody($exchange, ''); return; } @@ -41,12 +63,14 @@ public function emit(Exchange $exchange, Request $request, Response $response): $content = $response->getContent(); if ($content !== false) { $this->checkCancellation($exchange); - $exchange->writeBody($content); + $this->writeBody($exchange, $content); return; } - $this->stream($exchange, $response); + $this->sendContent($exchange, $response, true); + $this->checkCancellation($exchange); + $this->writeBody($exchange, ''); } /** @@ -67,50 +91,102 @@ private function headers(Response $response): array return $headers; } - private function stream(Exchange $exchange, Response $response): void + private function sendContent(Exchange $exchange, Response $response, bool $forward): void { - $level = \ob_get_level(); + $baseLevel = \ob_get_level(); $failure = null; - \ob_start(function (string $chunk) use ($exchange): string { - if ($chunk !== '') { + \ob_start(function (string $chunk) use ($exchange, $forward, &$failure): string { + if ($failure !== null || !$forward || $chunk === '') { + return ''; + } + + try { $this->checkCancellation($exchange); - $exchange->writeBody($chunk, false); + $this->writeBody($exchange, $chunk, false); + } catch (\Throwable $exception) { + $failure = $exception; } return ''; }, 1); + $ownedLevel = $baseLevel + 1; try { $response->sendContent(); - if (\ob_get_level() <= $level) { + $this->closeBuffersAbove($ownedLevel, true); + if (\ob_get_level() !== $ownedLevel) { throw new \LogicException('The response removed the Dispatcher output buffer.'); } - - while (\ob_get_level() > $level + 1) { - \ob_end_flush(); + if (!@\ob_end_flush()) { + throw new \LogicException('The Dispatcher output buffer could not be flushed.'); } - \ob_end_flush(); } catch (\Throwable $exception) { - $failure = $exception; + $failure ??= $exception; } finally { - while (\ob_get_level() > $level) { - \ob_end_clean(); + try { + $this->restoreBufferLevel($baseLevel); + } catch (\Throwable $exception) { + $failure ??= $exception; } } if ($failure !== null) { throw $failure; } + } - $this->checkCancellation($exchange); - $exchange->writeBody(''); + private function closeBuffersAbove(int $targetLevel, bool $flush): void + { + while (\ob_get_level() > $targetLevel) { + $level = \ob_get_level(); + $closed = $flush ? @\ob_end_flush() : @\ob_end_clean(); + if (!$closed || \ob_get_level() >= $level) { + throw new \LogicException('A nested output buffer cannot be removed safely.'); + } + } + } + + private function restoreBufferLevel(int $targetLevel): void + { + while (\ob_get_level() > $targetLevel) { + $level = \ob_get_level(); + if (!@\ob_end_clean() || \ob_get_level() >= $level) { + throw new \LogicException('The output buffer stack cannot be restored safely.'); + } + } + + if (\ob_get_level() < $targetLevel) { + throw new \LogicException('The response removed an output buffer it does not own.'); + } + } + + /** + * @param int<100, 599> $status + * @param array> $headers + */ + private function writeHead(Exchange $exchange, int $status, array $headers): void + { + try { + $exchange->writeHead($status, $headers); + } catch (WorkDiscardedException $exception) { + throw new ResponseDiscardedException($exception); + } + } + + private function writeBody(Exchange $exchange, string $content, bool $eos = true): void + { + try { + $exchange->writeBody($content, $eos); + } catch (WorkDiscardedException $exception) { + throw new ResponseDiscardedException($exception); + } } private function checkCancellation(Exchange $exchange): void { if ($exchange->isCancelled()) { - throw new WorkDiscardedException('The HTTP exchange was cancelled.'); + throw new ResponseDiscardedException(); } } } diff --git a/src/Internal/RequestLoop.php b/src/Internal/RequestLoop.php index 42290e4..4b31a0b 100644 --- a/src/Internal/RequestLoop.php +++ b/src/Internal/RequestLoop.php @@ -6,13 +6,14 @@ /** * @internal - * @psalm-mutable + * @psalm-suppress MissingInterfaceImmutableAnnotation */ interface RequestLoop { /** + * @psalm-suppress MissingAbstractPureAnnotation + * * @param callable(): bool $handler - * @psalm-impure */ public function handle(callable $handler): bool; } diff --git a/src/Internal/ResponseDiscardedException.php b/src/Internal/ResponseDiscardedException.php new file mode 100644 index 0000000..1e3cdb1 --- /dev/null +++ b/src/Internal/ResponseDiscardedException.php @@ -0,0 +1,21 @@ +headFailure !== null) { + throw $this->headFailure; + } + $this->heads[] = ['status' => $status, 'headers' => $headers]; } public function writeBody(string $content, bool $eos = true): void { + if ($this->bodyFailure !== null) { + throw $this->bodyFailure; + } + $this->bodies[] = ['content' => $content, 'eos' => $eos]; $this->finalized = $eos; } diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Unit/DispatcherRequestFactoryTest.php index 9f816af..9ff43a2 100644 --- a/tests/Unit/DispatcherRequestFactoryTest.php +++ b/tests/Unit/DispatcherRequestFactoryTest.php @@ -20,7 +20,22 @@ final class DispatcherRequestFactoryTest { public function convertsRequestWithoutMutatingGlobals(): void { - $_SERVER = ['APP_ENV' => 'test', 'HTTP_BOOT_ONLY' => 'no']; + $_SERVER = [ + 'APP_ENV' => 'test', + 'HTTP_BOOT_ONLY' => 'no', + 'REQUEST_METHOD' => 'BOOT', + 'REQUEST_URI' => '/boot', + 'SERVER_PROTOCOL' => 'HTTP/0.9', + 'REQUEST_TIME' => 1, + 'REQUEST_TIME_FLOAT' => 1.5, + 'HTTPS' => 'off', + 'REMOTE_ADDR' => '192.0.2.1', + 'REMOTE_PORT' => 1, + 'SERVER_ADDR' => '192.0.2.2', + 'SERVER_PORT' => 2, + 'CONTENT_TYPE' => 'text/boot', + 'CONTENT_LENGTH' => '999', + ]; $_GET = ['global' => 'unchanged']; $_POST = ['global' => 'unchanged']; $_COOKIE = ['global' => 'unchanged']; @@ -40,14 +55,26 @@ public function convertsRequestWithoutMutatingGlobals(): void 'Cookie' => ['a=1'], 'cookie' => ['b=2'], 'Content-Type' => ['application/json'], + 'Content-Length' => ['20'], ], body: "{\"binary\":\"\\u0000\"}", ))); $request = $converted->request; Assert::same($request->getMethod(), 'POST'); + Assert::same($request->server->get('RAPIRA_REQUEST_METHOD'), 'POST'); + Assert::same($request->attributes->get('rapira.request_method'), 'POST'); Assert::same($request->server->get('REQUEST_URI'), '/path?q=one&q=two&raw=%2F'); Assert::same($request->server->get('SERVER_PROTOCOL'), 'HTTP/2'); + Assert::same($request->server->get('REQUEST_TIME'), 1_700_000_000); + Assert::same($request->server->get('REQUEST_TIME_FLOAT'), 1_700_000_000.5); + Assert::same($request->server->get('HTTPS'), 'on'); + Assert::same($request->server->get('REMOTE_ADDR'), '127.0.0.1'); + Assert::same($request->server->get('REMOTE_PORT'), 40000); + Assert::same($request->server->get('SERVER_ADDR'), '127.0.0.1'); + Assert::same($request->server->get('SERVER_PORT'), 443); + Assert::same($request->server->get('CONTENT_TYPE'), 'application/json'); + Assert::same($request->server->get('CONTENT_LENGTH'), '20'); Assert::same($request->headers->all('x-repeat'), ['one', 'two']); Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); Assert::same($request->getContent(), "{\"binary\":\"\\u0000\"}"); diff --git a/tests/Unit/DispatcherRunnerTest.php b/tests/Unit/DispatcherRunnerTest.php index d00e082..d092652 100644 --- a/tests/Unit/DispatcherRunnerTest.php +++ b/tests/Unit/DispatcherRunnerTest.php @@ -57,7 +57,7 @@ public function cancellationContinuesToNextExchangeWithoutTerminatingCancelledRe $kernel = new LifecycleKernel($events); Assert::same($this->runner($kernel, $dispatcher)->run(), 0); - Assert::same($kernel->paths, ['/cancelled', '/healthy']); + Assert::same($kernel->paths, ['/healthy']); Assert::same($kernel->terminated, ['/healthy']); Assert::same($cancelled->heads, []); Assert::true($healthy->finalized); @@ -72,7 +72,17 @@ public function discardedWriteContinuesButUnexpectedKernelErrorsPropagate(): voi $kernel = new LifecycleKernel($events); Assert::same($this->runner($kernel, $dispatcher)->run(), 0); Assert::true($healthy->finalized); - Assert::same($kernel->terminated, ['/healthy']); + Assert::same($kernel->terminated, ['/discarded', '/healthy']); + + $events = []; + $dispatcher = new QueueHttpDispatcher([$this->exchange('/terminate-fail')], $events); + $kernel = new DiscardingTerminateKernel($events); + Expect::exception(WorkDiscardedException::class)->withMessage('application terminate discard'); + try { + $this->runner($kernel, $dispatcher)->run(); + } finally { + Assert::same($dispatcher->receiveCalls, 1); + } $events = []; $dispatcher = new QueueHttpDispatcher([$this->exchange('/fail'), $this->exchange('/must-not-run')], $events); @@ -208,6 +218,26 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c } } +final class DiscardingTerminateKernel implements HttpKernelInterface, TerminableInterface +{ + /** + * @param list $events + */ + public function __construct(private array &$events) {} + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->events[] = 'handle:' . $request->getPathInfo(); + + return new Response('ok'); + } + + public function terminate(Request $request, Response $response): void + { + throw new WorkDiscardedException('application terminate discard'); + } +} + final class DiscardingExchange extends StubExchange { public function writeHead(int $status, array $headers = []): void diff --git a/tests/Unit/ExchangeResponseEmitterTest.php b/tests/Unit/ExchangeResponseEmitterTest.php index e521964..c54e7c8 100644 --- a/tests/Unit/ExchangeResponseEmitterTest.php +++ b/tests/Unit/ExchangeResponseEmitterTest.php @@ -8,6 +8,7 @@ use Rapira\Http\Request as RapiraRequest; use Rapira\InetAddress; use Rapira\Symfony\Internal\ExchangeResponseEmitter; +use Rapira\Symfony\Internal\ResponseDiscardedException; use Rapira\Symfony\Tests\Support\StubExchange; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; @@ -36,20 +37,53 @@ public function emitsOrdinaryResponseOnceWithRepeatedHeadersAndCookies(): void Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); } - public function suppressesPreparedHeadAndEmptyResponseBodies(): void + public function ordinaryHeadRetainsPreparedContentLengthAndEmitsNoBody(): void { - foreach ([ - [Request::create('/', 'HEAD'), new Response('must-not-run')], - [Request::create('/'), new Response('must-not-run', 204)], - [Request::create('/'), new Response('must-not-run', 304)], - ] as [$request, $response]) { - $exchange = $this->exchange($request->getMethod()); - (new ExchangeResponseEmitter())->emit($exchange, $request, $response); + $exchange = $this->exchange('HEAD'); + $request = Request::create('/', 'HEAD'); + $request->attributes->set('rapira.request_method', 'HEAD'); + $response = new Response('must-not-send', headers: ['Content-Length' => '13']); + + (new ExchangeResponseEmitter())->emit($exchange, $request, $response); + + Assert::same($exchange->heads[0]['headers']['Content-Length'], ['13']); + Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); + } + + public function lowercaseHeadIsNotGivenStandardHeadTransportSemantics(): void + { + $exchange = $this->exchange('head'); + $request = Request::create('/', 'head'); + $request->attributes->set('rapira.request_method', 'head'); + + (new ExchangeResponseEmitter())->emit($exchange, $request, new Response('body')); + + Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); + } + + public function emptyResponsesEmitNoBody(): void + { + foreach ([204, 304] as $status) { + $exchange = $this->exchange(); + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('must-not-run', $status)); Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); Assert::false(isset($exchange->heads[0]['headers']['Content-Length'])); } } + public function rejectsTerminalInterimResponsesBeforeWriting(): void + { + $exchange = $this->exchange(); + Expect::exception(\LogicException::class) + ->withMessage('A terminal Symfony response cannot use an interim HTTP status.'); + try { + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('', 103)); + } finally { + Assert::same($exchange->heads, []); + Assert::same($exchange->bodies, []); + } + } + public function streamsProgressiveChunksAndRestoresOuterBuffer(): void { $exchange = $this->exchange(); @@ -63,6 +97,7 @@ public function streamsProgressiveChunksAndRestoresOuterBuffer(): void try { (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); Assert::same(\ob_get_level(), $level + 1); + Assert::same(\ob_get_contents(), ''); } finally { \ob_end_clean(); } @@ -74,43 +109,126 @@ public function streamsProgressiveChunksAndRestoresOuterBuffer(): void ]); } - public function restoresBuffersAndPropagatesStreamFailure(): void + public function capturesStreamingWriteFailureWithoutLeakingToOuterBuffer(): void { $exchange = $this->exchange(); + $exchange->bodyFailure = new WorkDiscardedException('gone'); $response = new StreamedResponse(static function (): void { - echo 'before'; - throw new \RuntimeException('stream failed'); + echo 'must-not-leak'; + \ob_flush(); + echo 'also-hidden'; }); - $level = \ob_get_level(); - Expect::exception(\RuntimeException::class)->withMessage('stream failed'); + \ob_start(); + Expect::exception(ResponseDiscardedException::class); try { (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); } finally { - Assert::same(\ob_get_level(), $level); - Assert::false($exchange->finalized); + Assert::same(\ob_get_contents(), ''); + \ob_end_clean(); } } - public function cancellationStopsBeforeCommit(): void + public function nonRemovableNestedBufferFailsWithoutHanging(): void { - $exchange = $this->exchange(); - $exchange->cancelled = true; + $script = \tempnam(\sys_get_temp_dir(), 'rapira-buffer-test-'); + \file_put_contents($script, <<<'PHP' +emit( + $exchange, + Symfony\Component\HttpFoundation\Request::create('/'), + $response, + ); +} catch (LogicException $exception) { + fwrite(STDERR, $exception->getMessage()); +} +PHP); + + $output = []; + $status = 0; + \exec('timeout 5 ' . \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($script) . ' 2>&1', $output, $status); + @\unlink($script); + + Assert::same($status, 0); + Assert::true(\str_contains(\implode("\n", $output), 'A nested output buffer cannot be removed safely.')); + } - Expect::exception(WorkDiscardedException::class); + public function eventStreamResponseIsRejectedWhenAvailable(): void + { + if (!\class_exists(\Symfony\Component\HttpFoundation\EventStreamResponse::class)) { + Assert::true(true); + + return; + } + + $exchange = $this->exchange(); + $response = new \Symfony\Component\HttpFoundation\EventStreamResponse(); + Expect::exception(\LogicException::class) + ->withMessage('Symfony EventStreamResponse is not supported in Rapira Dispatcher mode.'); try { - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('body')); + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); } finally { Assert::same($exchange->heads, []); - Assert::same($exchange->bodies, []); } } - public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void + public function binaryHeadRetainsFileLengthAndDeleteAfterSendCleansFile(): void { - $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); - \file_put_contents($path, '0123456789'); + $path = $this->file('0123456789'); + $exchange = $this->exchange('HEAD'); + $request = Request::create('/file', 'HEAD'); + $request->attributes->set('rapira.request_method', 'HEAD'); + $response = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); + $response->deleteFileAfterSend(); + + (new ExchangeResponseEmitter())->emit($exchange, $request, $response); + + Assert::same($exchange->heads[0]['headers']['Content-Length'], ['10']); + Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); + Assert::false(\is_file($path)); + } + public function emptyBinaryResponseRunsDeleteAfterSendCleanup(): void + { + foreach ([204, 304] as $status) { + $path = $this->file('cleanup'); + $response = new BinaryFileResponse( + $path, + $status, + ['Content-Type' => 'application/octet-stream'], + ); + $response->deleteFileAfterSend(); + $exchange = $this->exchange(); + + (new ExchangeResponseEmitter())->emit($exchange, Request::create('/file'), $response); + + Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); + Assert::false(\is_file($path)); + } + } + + public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void + { + $path = $this->file('0123456789'); $exchange = $this->exchange(); $request = Request::create('/file', 'GET', server: ['HTTP_RANGE' => 'bytes=2-5']); $response = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); @@ -133,10 +251,15 @@ public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void Assert::false(isset($unsatisfiable->heads[0]['headers']['Content-Length'])); Assert::same($unsatisfiable->bodies, [['content' => '', 'eos' => true]]); - $delete = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); - $delete->deleteFileAfterSend(); - (new ExchangeResponseEmitter())->emit($this->exchange(), Request::create('/file'), $delete); - Assert::false(\is_file($path)); + @\unlink($path); + } + + private function file(string $content): string + { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); + \file_put_contents($path, $content); + + return $path; } private function exchange(string $method = 'GET'): StubExchange From ef5fda69d381dec21fea4aed9e1ff5d030096882 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 15:08:35 +0400 Subject: [PATCH 05/42] chore: run workflows on the renamed 1.x branch Assisted-By: Claude Opus 5.5 --- .github/workflows/quality.yml | 2 +- .github/workflows/testing.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d32a6b7..1a403a9 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - main + - 1.x jobs: quality: diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e260ef1..6f7a82f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - main + - 1.x jobs: testing: From 353dd8e4c8aced4bda24131cc39fb431c6527701 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 15:15:52 +0400 Subject: [PATCH 06/42] chore: assign the copyright to Rapira Assisted-By: Claude Opus 5.5 --- LICENSE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index fad9923..2ad43cf 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # BSD 3-Clause -Copyright (c) 2026, Aleksei Gagarin (roxblnfk). +Copyright (c) 2026, Rapira. All rights reserved. Redistribution and use in source and binary forms, with or without From cc869a393eb1fdefb45f26a7adaf60d717774483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Tue, 6 Oct 2026 14:36:54 +0300 Subject: [PATCH 07/42] fix: use standard Symfony bootstrap with Rapira 0.9 --- README.md | 28 +-- composer.json | 3 +- src/DispatcherRunner.php | 2 +- src/Internal/DispatcherRequestFactory.php | 200 ++++++++++++++++++-- src/Internal/ExchangeResponseEmitter.php | 17 +- src/Internal/RequestExchange.php | 76 -------- src/Internal/autoload_runtime.template | 33 ---- tests/Support/StubExchange.php | 3 + tests/Unit/DispatcherRequestFactoryTest.php | 15 +- tests/Unit/ExchangeResponseEmitterTest.php | 1 + 10 files changed, 216 insertions(+), 162 deletions(-) delete mode 100644 src/Internal/RequestExchange.php delete mode 100644 src/Internal/autoload_runtime.template diff --git a/README.md b/README.md index a0008dc..1f13d92 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,27 @@ Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker and Dispatch ## Installation -The Rapira contract currently has no stable release, so require it explicitly: - ```shell -composer require rapira/symfony rapira/contract:dev-master +composer require rapira/symfony ``` -The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. +The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. The Rapira server must be version 0.9.0 or newer; Composer installs the PHP contracts but cannot enforce the version of the Rapira binary. ## Application setup -Add the Rapira-aware Runtime and autoload template to the application's `composer.json`: +Select the Runtime in the root application's `composer.json`: ```json { "extra": { "runtime": { - "autoload_template": "vendor/rapira/symfony/src/Internal/autoload_runtime.template", "class": "Rapira\\Symfony\\Runtime" } } } ``` -Run `composer dump-autoload`. Composer generates `vendor/autoload_runtime.php` with the Runtime class and conventional `public/index.php` entrypoint baked in. No `APP_RUNTIME` variable, separate worker script, or custom bootstrap is required: +Run `composer dump-autoload` after changing the Runtime configuration. Symfony generates its stock `vendor/autoload_runtime.php`; no custom autoload template or separate worker entrypoint is required. Keep the standard generated `public/index.php` unchanged: ```php =8.4", "nyholm/psr7": "^1.8", - "rapira/contract": "dev-master", + "rapira/contract": "0.9.0 as dev-master", "rapira/http": "^0.1", "symfony/http-foundation": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", @@ -28,6 +28,7 @@ "symfony/runtime": "^7.4 || ^8.0" }, "require-dev": { + "fidry/cpu-core-counter": "^1.2", "spiral/code-style": "^2.3.1", "testo/testo": "^0.10.42", "vimeo/psalm": "^7" diff --git a/src/DispatcherRunner.php b/src/DispatcherRunner.php index 62cf3f4..3deb3e7 100644 --- a/src/DispatcherRunner.php +++ b/src/DispatcherRunner.php @@ -47,7 +47,7 @@ public function run(): int continue; } - $converted = $this->requestFactory->create($exchange); + $converted = $this->requestFactory->create($exchange->getRequest()); $request = $converted->request; $response = $this->kernel->handle($request); diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 8beed92..5a2a347 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -5,9 +5,10 @@ namespace Rapira\Symfony\Internal; use Nyholm\Psr7\Factory\Psr17Factory; -use Rapira\Http\Exchange; +use Psr\Http\Message\ServerRequestInterface; +use Rapira\Http\Multipart; use Rapira\Http\Request as RapiraRequest; -use Rapira\Sdk\Http\DispatcherRequestFactory as PsrRequestFactory; +use Rapira\Http\UploadedFile as RapiraUploadedFile; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -20,7 +21,7 @@ /** @var array */ private array $server; - private PsrRequestFactory $psrFactory; + private Psr17Factory $psr17Factory; private HttpFoundationFactory $httpFoundationFactory; /** @@ -34,18 +35,13 @@ public function __construct(?array $server = null) \ARRAY_FILTER_USE_KEY, ); - $psr17Factory = new Psr17Factory(); - $this->psrFactory = new PsrRequestFactory($psr17Factory, $psr17Factory, $psr17Factory); + $this->psr17Factory = new Psr17Factory(); $this->httpFoundationFactory = new HttpFoundationFactory(); } - public function create(Exchange $exchange): DispatcherRequest + public function create(RapiraRequest $source): DispatcherRequest { - $source = $exchange->getRequest(); - $psrRequest = $this->psrFactory->create(new RequestExchange( - $exchange, - $this->normalizeRequest($source), - )); + $psrRequest = $this->createPsrRequest($this->normalizeRequest($source)); $psrRequest = $psrRequest->withCookieParams($this->parseCookies($source->headers)); $request = $this->httpFoundationFactory->createRequest($psrRequest); @@ -57,6 +53,188 @@ public function create(Exchange $exchange): DispatcherRequest return new DispatcherRequest($request, $this->uploadPaths($request)); } + private function createPsrRequest(RapiraRequest $source): ServerRequestInterface + { + $request = $this->psr17Factory->createServerRequest( + $source->method, + $source->uri, + $this->serverParams($source), + ); + $request = $request->withProtocolVersion(\str_replace('HTTP/', '', $source->protocol)); + + foreach ($source->headers as $name => $values) { + $request = $request->withHeader($name, $values); + } + + $query = []; + \parse_str($request->getUri()->getQuery(), $query); + $request = $request->withQueryParams($query); + + if ($source->body instanceof Multipart) { + return $request + ->withBody($this->psr17Factory->createStream()) + ->withParsedBody($this->parseFields($source->body)) + ->withUploadedFiles($this->createUploadedFiles($source->body)); + } + + $request = $request->withBody($this->psr17Factory->createStream($source->body)); + if (\preg_match('~^application/x-www-form-urlencoded(?:$| |;)~', $request->getHeaderLine('content-type')) === 1) { + $parsed = []; + \parse_str($source->body, $parsed); + $request = $request->withParsedBody($parsed); + } + + return $request; + } + + /** + * @psalm-suppress MissingPureAnnotation + * + * @return array + */ + private function serverParams(RapiraRequest $request): array + { + $params = [ + 'REQUEST_METHOD' => $request->method, + 'REQUEST_URI' => $request->target, + 'SERVER_PROTOCOL' => $request->protocol, + 'REQUEST_TIME' => (int) $request->receivedAt, + 'REQUEST_TIME_FLOAT' => $request->receivedAt, + ]; + + if ($request->authority !== null) { + $params['HTTP_HOST'] = $request->authority; + } + if ($request->tls !== null) { + $params['HTTPS'] = 'on'; + } + if ($request->remote instanceof \Rapira\InetAddress) { + $params['REMOTE_ADDR'] = $request->remote->ip; + $params['REMOTE_PORT'] = $request->remote->port; + } elseif ($request->remote->path !== null) { + $params['REMOTE_ADDR'] = $request->remote->path; + } + if ($request->server instanceof \Rapira\InetAddress) { + $params['SERVER_ADDR'] = $request->server->ip; + $params['SERVER_PORT'] = $request->server->port; + } elseif ($request->server->path !== null) { + $params['SERVER_ADDR'] = $request->server->path; + } + + foreach ($request->headers as $name => $values) { + $key = \strtoupper(\str_replace('-', '_', $name)); + if ($key !== 'CONTENT_TYPE' && $key !== 'CONTENT_LENGTH') { + $key = 'HTTP_' . $key; + } + $params[$key] = \implode(', ', $values); + } + + return $params; + } + + /** + * @return array + */ + private function parseFields(Multipart $multipart): array + { + $pairs = []; + foreach ($multipart->fields as $field) { + $pairs[] = \urlencode($field->name) . '=' . \urlencode($field->value); + } + + $result = []; + \parse_str(\implode('&', $pairs), $result); + + return $result; + } + + /** + * @return array + */ + private function createUploadedFiles(Multipart $multipart): array + { + $files = []; + foreach ($multipart->files as $file) { + $this->addNested($files, $file->name, $this->createUploadedFile($file)); + } + + return $files; + } + + private function createUploadedFile(RapiraUploadedFile $file): \Psr\Http\Message\UploadedFileInterface + { + try { + $stream = $this->psr17Factory->createStreamFromFile($file->tmpPath); + } catch (\RuntimeException) { + $stream = $this->psr17Factory->createStream(); + } + + return $this->psr17Factory->createUploadedFile( + $stream, + $file->size, + $file->clientFilename === '' ? \UPLOAD_ERR_NO_FILE : \UPLOAD_ERR_OK, + $file->clientFilename, + $file->clientMediaType, + ); + } + + /** + * @psalm-suppress MissingPureAnnotation, MixedAssignment + * + * @param array $target + */ + private function addNested(array &$target, string $name, mixed $value): void + { + if (\preg_match('/^([^\[]+)((?:\[[^\]]*])*)$/', $name, $matches) !== 1) { + $target[$name] = $value; + + return; + } + + $keys = [$matches[1]]; + if ($matches[2] !== '') { + \preg_match_all('/\[([^\]]*)]/', $matches[2], $bracketed); + foreach ($bracketed[1] as $key) { + $keys[] = $key; + } + } + + $this->insert($target, $keys, $value); + } + + /** + * @param array $target + * @param list $keys + * + * @psalm-suppress MissingPureAnnotation, MixedAssignment, MixedArrayAssignment, MixedArgument + */ + private function insert(array &$target, array $keys, mixed $value): void + { + $key = \array_shift($keys); + if ($key === null) { + return; + } + if ($key === '') { + $target[] = $keys === [] ? $value : []; + if ($keys !== []) { + /** @var array-key $last */ + $last = \array_key_last($target); + $this->insert($target[$last], $keys, $value); + } + + return; + } + if ($keys === []) { + $target[$key] = $value; + + return; + } + if (!isset($target[$key]) || !\is_array($target[$key])) { + $target[$key] = []; + } + $this->insert($target[$key], $keys, $value); + } + private function normalizeRequest(RapiraRequest $request): RapiraRequest { /** @var array> $headers */ diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 59eea98..6262088 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -44,17 +44,16 @@ public function emit(Exchange $exchange, Request $request, Response $response): $response->headers->remove('Content-Length'); } - $this->checkCancellation($exchange); /** @var int<100, 599> $status */ $status = $response->getStatusCode(); - $this->writeHead($exchange, $status, $this->headers($response)); + $headers = $this->headers($response); if ($rawMethod === 'HEAD' || $response->isEmpty()) { + $this->writeHead($exchange, $status, $headers); if ($response instanceof BinaryFileResponse) { $this->sendContent($exchange, $response, false); } - $this->checkCancellation($exchange); $this->writeBody($exchange, ''); return; @@ -62,14 +61,14 @@ public function emit(Exchange $exchange, Request $request, Response $response): $content = $response->getContent(); if ($content !== false) { - $this->checkCancellation($exchange); + $this->writeHead($exchange, $status, $headers); $this->writeBody($exchange, $content); return; } + $this->writeHead($exchange, $status, $headers); $this->sendContent($exchange, $response, true); - $this->checkCancellation($exchange); $this->writeBody($exchange, ''); } @@ -102,7 +101,6 @@ private function sendContent(Exchange $exchange, Response $response, bool $forwa } try { - $this->checkCancellation($exchange); $this->writeBody($exchange, $chunk, false); } catch (\Throwable $exception) { $failure = $exception; @@ -182,11 +180,4 @@ private function writeBody(Exchange $exchange, string $content, bool $eos = true throw new ResponseDiscardedException($exception); } } - - private function checkCancellation(Exchange $exchange): void - { - if ($exchange->isCancelled()) { - throw new ResponseDiscardedException(); - } - } } diff --git a/src/Internal/RequestExchange.php b/src/Internal/RequestExchange.php deleted file mode 100644 index b222500..0000000 --- a/src/Internal/RequestExchange.php +++ /dev/null @@ -1,76 +0,0 @@ -request; - } - - #[\Override] - public function isFinalized(): bool - { - return $this->exchange->isFinalized(); - } - - #[\Override] - public function isCancelled(): bool - { - return $this->exchange->isCancelled(); - } - - #[\Override] - public function writeHead(int $status, array $headers = []): void - { - $this->exchange->writeHead($status, $headers); - } - - #[\Override] - public function writeBody(string $content, bool $eos = true): void - { - $this->exchange->writeBody($content, $eos); - } - - #[\Override] - public function sendFile(string $path, int $offset = 0, ?int $length = null, bool $eos = true): void - { - $this->exchange->sendFile($path, $offset, $length, $eos); - } - - #[\Override] - public function writeTrailers(array $trailers): void - { - $this->exchange->writeTrailers($trailers); - } - - #[\Override] - public function flush(): void - { - $this->exchange->flush(); - } - - /** - * @psalm-mutation-free - */ - #[\Override] - public function __destruct() {} -} diff --git a/src/Internal/autoload_runtime.template b/src/Internal/autoload_runtime.template deleted file mode 100644 index 07466fa..0000000 --- a/src/Internal/autoload_runtime.template +++ /dev/null @@ -1,33 +0,0 @@ -getResolver($app) - ->resolve(); - -$app = $app(...$args); - -exit( - $runtime - ->getRunner($app) - ->run(), -); diff --git a/tests/Support/StubExchange.php b/tests/Support/StubExchange.php index 780c621..708f0a9 100644 --- a/tests/Support/StubExchange.php +++ b/tests/Support/StubExchange.php @@ -17,6 +17,7 @@ class StubExchange implements Exchange public bool $cancelled = false; public bool $finalized = false; + public int $cancellationChecks = 0; public ?\Throwable $headFailure = null; public ?\Throwable $bodyFailure = null; @@ -34,6 +35,8 @@ public function isFinalized(): bool public function isCancelled(): bool { + ++$this->cancellationChecks; + return $this->cancelled; } diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Unit/DispatcherRequestFactoryTest.php index 9ff43a2..2b54729 100644 --- a/tests/Unit/DispatcherRequestFactoryTest.php +++ b/tests/Unit/DispatcherRequestFactoryTest.php @@ -10,7 +10,6 @@ use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; use Rapira\Symfony\Internal\DispatcherRequestFactory; -use Rapira\Symfony\Tests\Support\StubExchange; use Symfony\Component\HttpFoundation\File\UploadedFile; use Testo\Assert; use Testo\Test; @@ -43,7 +42,7 @@ public function convertsRequestWithoutMutatingGlobals(): void $globals = [$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES]; $factory = new DispatcherRequestFactory($_SERVER); - $converted = $factory->create(new StubExchange(self::request( + $converted = $factory->create(self::request( method: 'POST', uri: 'https://example.test/path?q=one&q=two', target: '/path?q=one&q=two&raw=%2F', @@ -58,7 +57,7 @@ public function convertsRequestWithoutMutatingGlobals(): void 'Content-Length' => ['20'], ], body: "{\"binary\":\"\\u0000\"}", - ))); + )); $request = $converted->request; Assert::same($request->getMethod(), 'POST'); @@ -85,11 +84,11 @@ public function convertsRequestWithoutMutatingGlobals(): void public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies(): void { - $form = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request( + $form = (new DispatcherRequestFactory([]))->create(self::request( method: 'POST', headers: ['Content-Type' => ['application/x-www-form-urlencoded']], body: 'user[name]=Ada&items[]=one&items[]=two', - )))->request; + ))->request; Assert::same($form->request->all(), [ 'user' => ['name' => 'Ada'], 'items' => ['one', 'two'], @@ -104,10 +103,10 @@ public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies() new RapiraUploadedFile('empty', '', null, [], $source, 0), ], ); - $converted = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request( + $converted = (new DispatcherRequestFactory([]))->create(self::request( method: 'POST', body: $multipart, - ))); + )); $request = $converted->request; Assert::same($request->request->all(), ['meta' => ['name' => 'Ada']]); @@ -133,7 +132,7 @@ public function leavesMovedUploadAliveAfterCleanup(): void $multipart = new Multipart([], [ new RapiraUploadedFile('file', 'file.txt', 'text/plain', [], $source, 4), ]); - $converted = (new DispatcherRequestFactory([]))->create(new StubExchange(self::request(body: $multipart))); + $converted = (new DispatcherRequestFactory([]))->create(self::request(body: $multipart)); $destination = \tempnam(\sys_get_temp_dir(), 'rapira-moved-'); @\unlink($destination); $converted->request->files->get('file')->move(\dirname($destination), \basename($destination)); diff --git a/tests/Unit/ExchangeResponseEmitterTest.php b/tests/Unit/ExchangeResponseEmitterTest.php index c54e7c8..14fb2e6 100644 --- a/tests/Unit/ExchangeResponseEmitterTest.php +++ b/tests/Unit/ExchangeResponseEmitterTest.php @@ -35,6 +35,7 @@ public function emitsOrdinaryResponseOnceWithRepeatedHeadersAndCookies(): void Assert::same($exchange->heads[0]['headers']['X-Repeat'], ['one', 'two']); Assert::count($exchange->heads[0]['headers']['Set-Cookie'], 2); Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); + Assert::same($exchange->cancellationChecks, 0); } public function ordinaryHeadRetainsPreparedContentLengthAndEmitsNoBody(): void From bcdaa06f1ee3c270d58066cd51cd2de1af5180f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Tue, 6 Oct 2026 14:39:24 +0300 Subject: [PATCH 08/42] fix: use stable Rapira contract dependency --- README.md | 2 +- composer.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1f13d92..f4e17c0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Start Rapira with `rapira serve rapira.toml`. ## Execution modes -- **Dispatcher** converts each `Rapira\Http\Exchange` through `rapira/http` and the Symfony PSR-7 bridge, then writes the HttpFoundation response directly to the exchange. Requests are processed sequentially. +- **Dispatcher** converts each `Rapira\Http\Exchange` to a Symfony request and writes the HttpFoundation response directly to the exchange. Requests are processed sequentially. - **Worker** uses Rapira's SAPI request loop and Symfony's normal `Response::send()` lifecycle. - **Classic** delegates to Symfony's standard runtime behavior. diff --git a/composer.json b/composer.json index b2ddddc..8772a49 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,7 @@ "require": { "php": ">=8.4", "nyholm/psr7": "^1.8", - "rapira/contract": "0.9.0 as dev-master", - "rapira/http": "^0.1", + "rapira/contract": "^0.9", "symfony/http-foundation": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", "symfony/psr-http-message-bridge": "^7.4 || ^8.0", From a180f443a51a4f6e1b736d27a61a5d6b70f48675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Tue, 6 Oct 2026 14:58:47 +0300 Subject: [PATCH 09/42] fix: correct Rapira 0.9 request metadata --- README.md | 6 +- composer.json | 2 +- src/Internal/DispatcherRequestFactory.php | 187 ++++++++++++++------ tests/Unit/DispatcherRequestFactoryTest.php | 95 +++++++++- 4 files changed, 230 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index f4e17c0..003b603 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker and Dispatch ## Installation +After the first release is published on Packagist, install the package normally: + ```shell composer require rapira/symfony ``` +Until then, applications testing this branch can require `rapira/symfony:dev-feat/symfony-runtime`. + The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. The Rapira server must be version 0.9.0 or newer; Composer installs the PHP contracts but cannot enforce the version of the Rapira binary. ## Application setup @@ -44,7 +48,7 @@ Alternatively, leave `extra.runtime.class` unset and export the Runtime when Rap APP_RUNTIME='Rapira\Symfony\Runtime' rapira serve rapira.toml ``` -Rapira 0.9.0 imports process environment variables into boot-time `$_SERVER` and `$_ENV`, so Symfony's stock bootstrap sees `APP_RUNTIME`, `APP_ENV`, and `APP_DEBUG`. +Rapira 0.9.0 imports process environment variables into boot-time `$_SERVER`, which Symfony's stock bootstrap checks before `$_ENV` for `APP_RUNTIME`, `APP_ENV`, and `APP_DEBUG`. PHP also populates `$_ENV` when `variables_order` contains `E`. Configure Rapira with the current plugin-scoped pool syntax and choose `worker` or `dispatcher`: diff --git a/composer.json b/composer.json index 8772a49..489dbb1 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-main": "1.x-dev" + "dev-1.x": "1.x-dev" } }, "config": { diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 5a2a347..ac5242c 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -8,16 +8,38 @@ use Psr\Http\Message\ServerRequestInterface; use Rapira\Http\Multipart; use Rapira\Http\Request as RapiraRequest; -use Rapira\Http\UploadedFile as RapiraUploadedFile; +use Rapira\InetAddress; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Symfony\Component\HttpFoundation\Request; /** * @internal */ final readonly class DispatcherRequestFactory { + private const NON_HTTP_BOOT_SERVER_KEYS = [ + 'DOCUMENT_ROOT', + 'PATH_TRANSLATED', + 'PHP_SELF', + 'SCRIPT_FILENAME', + 'SCRIPT_NAME', + ]; + private const REQUEST_SERVER_PREFIXES = [ + 'AUTH_', + 'CONTENT_', + 'HTTP_', + 'REMOTE_', + 'REQUEST_', + 'SERVER_', + ]; + private const REQUEST_SERVER_KEYS = [ + 'GATEWAY_INTERFACE', + 'HTTPS', + 'ORIG_PATH_INFO', + 'PATH_INFO', + 'REDIRECT_STATUS', + ]; + /** @var array */ private array $server; @@ -29,9 +51,25 @@ */ public function __construct(?array $server = null) { + $sourceServer = $server ?? $_SERVER; $this->server = \array_filter( - $server ?? $_SERVER, - static fn(mixed $key): bool => !\str_starts_with((string) $key, 'HTTP_'), + $sourceServer, + static function (mixed $key): bool { + if (\in_array($key, self::NON_HTTP_BOOT_SERVER_KEYS, true)) { + return true; + } + if (\in_array($key, self::REQUEST_SERVER_KEYS, true)) { + return false; + } + + foreach (self::REQUEST_SERVER_PREFIXES as $prefix) { + if (\str_starts_with($key, $prefix)) { + return false; + } + } + + return true; + }, \ARRAY_FILTER_USE_KEY, ); @@ -43,14 +81,37 @@ public function create(RapiraRequest $source): DispatcherRequest { $psrRequest = $this->createPsrRequest($this->normalizeRequest($source)); $psrRequest = $psrRequest->withCookieParams($this->parseCookies($source->headers)); + $uploadPaths = []; + + try { + if ($source->body instanceof Multipart) { + [$files, $uploadPaths] = $this->createHttpFoundationFiles($source->body); + $psrRequest = $psrRequest->withUploadedFiles([]); + } else { + $files = []; + } + + $request = $this->httpFoundationFactory->createRequest($psrRequest); + /** @psalm-suppress MixedArgumentTypeCoercion */ + $request->files->replace($files); + } catch (\Throwable $exception) { + $this->cleanupUploadPaths($uploadPaths); - $request = $this->httpFoundationFactory->createRequest($psrRequest); + throw $exception; + } $request->server->replace($request->server->all() + $this->server); $request->server->set('REQUEST_URI', $source->target); $request->server->set('RAPIRA_REQUEST_METHOD', $source->method); + if ($source->server instanceof InetAddress) { + $request->server->set('SERVER_ADDR', $source->server->ip); + $request->server->set('SERVER_PORT', $source->server->port); + } else { + $request->server->remove('SERVER_ADDR'); + $request->server->remove('SERVER_PORT'); + } $request->attributes->set('rapira.request_method', $source->method); - return new DispatcherRequest($request, $this->uploadPaths($request)); + return new DispatcherRequest($request, $uploadPaths); } private function createPsrRequest(RapiraRequest $source): ServerRequestInterface @@ -73,8 +134,7 @@ private function createPsrRequest(RapiraRequest $source): ServerRequestInterface if ($source->body instanceof Multipart) { return $request ->withBody($this->psr17Factory->createStream()) - ->withParsedBody($this->parseFields($source->body)) - ->withUploadedFiles($this->createUploadedFiles($source->body)); + ->withParsedBody($this->parseFields($source->body)); } $request = $request->withBody($this->psr17Factory->createStream($source->body)); @@ -108,13 +168,13 @@ private function serverParams(RapiraRequest $request): array if ($request->tls !== null) { $params['HTTPS'] = 'on'; } - if ($request->remote instanceof \Rapira\InetAddress) { + if ($request->remote instanceof InetAddress) { $params['REMOTE_ADDR'] = $request->remote->ip; $params['REMOTE_PORT'] = $request->remote->port; } elseif ($request->remote->path !== null) { $params['REMOTE_ADDR'] = $request->remote->path; } - if ($request->server instanceof \Rapira\InetAddress) { + if ($request->server instanceof InetAddress) { $params['SERVER_ADDR'] = $request->server->ip; $params['SERVER_PORT'] = $request->server->port; } elseif ($request->server->path !== null) { @@ -149,33 +209,77 @@ private function parseFields(Multipart $multipart): array } /** - * @return array + * @return array{array, list} + * + * @psalm-suppress MixedReturnTypeCoercion */ - private function createUploadedFiles(Multipart $multipart): array + private function createHttpFoundationFiles(Multipart $multipart): array { $files = []; - foreach ($multipart->files as $file) { - $this->addNested($files, $file->name, $this->createUploadedFile($file)); + /** @var list $paths */ + $paths = []; + + try { + foreach ($multipart->files as $file) { + if ($file->clientFilename === '') { + $uploaded = new UploadedFile('', '', null, \UPLOAD_ERR_NO_FILE, true); + } else { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-upload-'); + if ($path === false) { + throw new \RuntimeException('Unable to create a temporary upload file.'); + } + $paths[] = $path; + + $source = @\fopen($file->tmpPath, 'rb'); + if ($source === false) { + throw new \RuntimeException(\sprintf('Unable to read Rapira upload "%s".', $file->tmpPath)); + } + $target = @\fopen($path, 'wb'); + if ($target === false) { + \fclose($source); + + throw new \RuntimeException(\sprintf('Unable to write temporary upload "%s".', $path)); + } + + try { + if (\stream_copy_to_stream($source, $target) === false) { + throw new \RuntimeException(\sprintf('Unable to copy Rapira upload "%s".', $file->tmpPath)); + } + } finally { + \fclose($source); + \fclose($target); + } + + $uploaded = new UploadedFile( + $path, + $file->clientFilename, + $file->clientMediaType, + \UPLOAD_ERR_OK, + true, + ); + } + + $this->addNested($files, $file->name, $uploaded); + } + } catch (\Throwable $exception) { + $this->cleanupUploadPaths($paths); + + throw $exception; } - return $files; + return [$files, $paths]; } - private function createUploadedFile(RapiraUploadedFile $file): \Psr\Http\Message\UploadedFileInterface + /** + * @param list $paths + */ + private function cleanupUploadPaths(array $paths): void { - try { - $stream = $this->psr17Factory->createStreamFromFile($file->tmpPath); - } catch (\RuntimeException) { - $stream = $this->psr17Factory->createStream(); + foreach ($paths as $path) { + if (\is_file($path)) { + @\unlink($path); + } } - - return $this->psr17Factory->createUploadedFile( - $stream, - $file->size, - $file->clientFilename === '' ? \UPLOAD_ERR_NO_FILE : \UPLOAD_ERR_OK, - $file->clientFilename, - $file->clientMediaType, - ); } /** @@ -294,31 +398,4 @@ private function parseCookies(array $headers): array return $cookies; } - - /** - * @return list - */ - private function uploadPaths(Request $request): array - { - $paths = []; - $files = $request->files->all(); - /** @psalm-suppress MixedAssignment */ - $walk = static function (array $files) use (&$walk, &$paths): void { - foreach ($files as $file) { - if ($file instanceof UploadedFile) { - if ($file->getError() !== \UPLOAD_ERR_NO_FILE) { - $paths[] = $file->getPathname(); - } - continue; - } - - if (\is_array($file)) { - $walk($file); - } - } - }; - $walk($files); - - return $paths; - } } diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Unit/DispatcherRequestFactoryTest.php index 2b54729..ab235bc 100644 --- a/tests/Unit/DispatcherRequestFactoryTest.php +++ b/tests/Unit/DispatcherRequestFactoryTest.php @@ -9,9 +9,11 @@ use Rapira\Http\Request as RapiraRequest; use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; +use Rapira\UnixAddress; use Rapira\Symfony\Internal\DispatcherRequestFactory; use Symfony\Component\HttpFoundation\File\UploadedFile; use Testo\Assert; +use Testo\Expect; use Testo\Test; #[Test] @@ -21,6 +23,8 @@ public function convertsRequestWithoutMutatingGlobals(): void { $_SERVER = [ 'APP_ENV' => 'test', + 'APP_CUSTOM_SETTING' => 'kept', + 'SCRIPT_FILENAME' => '/app/public/index.php', 'HTTP_BOOT_ONLY' => 'no', 'REQUEST_METHOD' => 'BOOT', 'REQUEST_URI' => '/boot', @@ -71,17 +75,65 @@ public function convertsRequestWithoutMutatingGlobals(): void Assert::same($request->server->get('REMOTE_ADDR'), '127.0.0.1'); Assert::same($request->server->get('REMOTE_PORT'), 40000); Assert::same($request->server->get('SERVER_ADDR'), '127.0.0.1'); - Assert::same($request->server->get('SERVER_PORT'), 443); + Assert::same($request->server->get('SERVER_PORT'), 8080); Assert::same($request->server->get('CONTENT_TYPE'), 'application/json'); Assert::same($request->server->get('CONTENT_LENGTH'), '20'); Assert::same($request->headers->all('x-repeat'), ['one', 'two']); Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); Assert::same($request->getContent(), "{\"binary\":\"\\u0000\"}"); Assert::same($request->server->get('APP_ENV'), 'test'); + Assert::same($request->server->get('APP_CUSTOM_SETTING'), 'kept'); + Assert::same($request->server->get('SCRIPT_FILENAME'), '/app/public/index.php'); Assert::null($request->headers->get('boot-only')); Assert::same([$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES], $globals); } + public function omitsStaleBootRequestMetadataAndKeepsPhysicalListener(): void + { + $boot = [ + 'APP_ENV' => 'test', + 'AUTH_TYPE' => 'Basic', + 'CONTENT_LENGTH' => '999', + 'CONTENT_TYPE' => 'text/boot', + 'HTTPS' => 'on', + 'REMOTE_ADDR' => '192.0.2.1', + 'REMOTE_PORT' => 1, + 'REQUEST_SCHEME' => 'https', + 'SERVER_ADDR' => '192.0.2.2', + 'SERVER_PORT' => 2, + 'SERVER_PROTOCOL' => 'HTTP/0.9', + ]; + $factory = new DispatcherRequestFactory($boot); + + $internet = $factory->create(self::request( + uri: 'http://public.example:9443/plain', + target: '/plain', + remote: new UnixAddress(null), + server: new InetAddress('10.0.0.8', 8443), + ))->request; + Assert::same($internet->server->get('SERVER_ADDR'), '10.0.0.8'); + Assert::same($internet->server->get('SERVER_PORT'), 8443); + Assert::same($internet->server->get('SERVER_PROTOCOL'), 'HTTP/1.1'); + Assert::same($internet->server->get('APP_ENV'), 'test'); + foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'REMOTE_ADDR', 'REMOTE_PORT', 'REQUEST_SCHEME'] as $key) { + Assert::false($internet->server->has($key), $key); + } + + $unix = $factory->create(self::request( + uri: 'http://localhost/unix', + target: '/unix', + remote: new UnixAddress(null), + server: new UnixAddress('/run/rapira.sock'), + ))->request; + Assert::false($unix->server->has('SERVER_ADDR')); + Assert::false($unix->server->has('SERVER_PORT')); + Assert::false($unix->server->has('REMOTE_ADDR')); + Assert::false($unix->server->has('REMOTE_PORT')); + Assert::false($unix->server->has('HTTPS')); + Assert::false($unix->server->has('CONTENT_TYPE')); + Assert::false($unix->server->has('CONTENT_LENGTH')); + } + public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies(): void { $form = (new DispatcherRequestFactory([]))->create(self::request( @@ -125,6 +177,28 @@ public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies() @\unlink($source); } + public function rejectsUnreadableNamedUploadWithoutCreatingTemporaryFiles(): void + { + $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); + \file_put_contents($source, 'first'); + $before = self::uploadTempPaths(); + $missing = \sys_get_temp_dir() . '/rapira-missing-' . \bin2hex(\random_bytes(8)); + $multipart = new Multipart([], [ + new RapiraUploadedFile('first', 'first.txt', 'text/plain', [], $source, 5), + new RapiraUploadedFile('empty', '', null, [], $missing, 0), + new RapiraUploadedFile('file', 'named.txt', 'text/plain', [], $missing, 10), + ]); + + Expect::exception(\RuntimeException::class) + ->withMessage(\sprintf('Unable to read Rapira upload "%s".', $missing)); + try { + (new DispatcherRequestFactory([]))->create(self::request(body: $multipart)); + } finally { + Assert::same(self::uploadTempPaths(), $before); + @\unlink($source); + } + } + public function leavesMovedUploadAliveAfterCleanup(): void { $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); @@ -144,12 +218,25 @@ public function leavesMovedUploadAliveAfterCleanup(): void @\unlink($source); } + /** + * @return list + */ + private static function uploadTempPaths(): array + { + $paths = \glob(\sys_get_temp_dir() . '/rapira-upload-*') ?: []; + \sort($paths); + + return $paths; + } + /** * @param non-empty-string $method * @param non-empty-string $uri * @param non-empty-string $target * @param non-empty-string $protocol * @param array> $headers + * @param InetAddress|UnixAddress $remote + * @param InetAddress|UnixAddress $server */ private static function request( string $method = 'GET', @@ -158,6 +245,8 @@ private static function request( string $protocol = 'HTTP/1.1', array $headers = [], string|Multipart $body = '', + InetAddress|UnixAddress|null $remote = null, + InetAddress|UnixAddress|null $server = null, ): RapiraRequest { return new RapiraRequest( $method, @@ -167,8 +256,8 @@ private static function request( $protocol, $headers, $body, - new InetAddress('127.0.0.1', 40000), - new InetAddress('127.0.0.1', 8080), + $remote ?? new InetAddress('127.0.0.1', 40000), + $server ?? new InetAddress('127.0.0.1', 8080), null, 1_700_000_000.5, ); From be617450b3d08e14ca6b1cc7da369a7906a3ac99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Tue, 6 Oct 2026 17:17:16 +0300 Subject: [PATCH 10/42] fix: isolate unit test globals --- tests/Support/IsolatesProcessState.php | 59 +++++++++++++++++++ tests/Unit/ARuntimeTest.php | 3 + tests/Unit/DispatcherRequestFactoryTest.php | 3 + tests/Unit/DispatcherRunnerTest.php | 11 ++-- tests/Unit/ExchangeResponseEmitterTest.php | 63 +++++++++++++++++++-- tests/Unit/WorkerRunnerTest.php | 27 ++++++--- 6 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 tests/Support/IsolatesProcessState.php diff --git a/tests/Support/IsolatesProcessState.php b/tests/Support/IsolatesProcessState.php new file mode 100644 index 0000000..a6abb54 --- /dev/null +++ b/tests/Support/IsolatesProcessState.php @@ -0,0 +1,59 @@ +superglobals = [ + 'server' => $_SERVER, + 'env' => $_ENV, + 'get' => $_GET, + 'post' => $_POST, + 'cookie' => $_COOKIE, + 'files' => $_FILES, + 'request' => $_REQUEST, + ]; + $this->ignoreUserAbort = \ignore_user_abort(); + $this->outputBufferLevel = \ob_get_level(); + } + + #[AfterTest] + public function restoreProcessState(): void + { + try { + $this->restoreOutputBufferLevel($this->outputBufferLevel); + } finally { + $_SERVER = $this->superglobals['server']; + $_ENV = $this->superglobals['env']; + $_GET = $this->superglobals['get']; + $_POST = $this->superglobals['post']; + $_COOKIE = $this->superglobals['cookie']; + $_FILES = $this->superglobals['files']; + $_REQUEST = $this->superglobals['request']; + \ignore_user_abort((bool) $this->ignoreUserAbort); + } + } + + private function restoreOutputBufferLevel(int $level): void + { + while (\ob_get_level() > $level) { + if (!@\ob_end_clean()) { + throw new \RuntimeException('Unable to restore the output buffer level.'); + } + } + } +} diff --git a/tests/Unit/ARuntimeTest.php b/tests/Unit/ARuntimeTest.php index b03e58b..cfc42a8 100644 --- a/tests/Unit/ARuntimeTest.php +++ b/tests/Unit/ARuntimeTest.php @@ -6,6 +6,7 @@ use Rapira\Mode; use Rapira\Symfony\DispatcherRunner; +use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Rapira\Symfony\Tests\Support\NullKernel; use Rapira\Symfony\Tests\Support\RuntimeForMode; use Rapira\Symfony\Tests\Support\StubHttpDispatcher; @@ -17,6 +18,8 @@ #[Test] final class RuntimeTest { + use IsolatesProcessState; + public function workerModeSelectsWorkerRunnerAndSetsRuntimeModeBeforeSymfonyInitialization(): void { unset($_SERVER['APP_RUNTIME_MODE']); diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Unit/DispatcherRequestFactoryTest.php index ab235bc..974a527 100644 --- a/tests/Unit/DispatcherRequestFactoryTest.php +++ b/tests/Unit/DispatcherRequestFactoryTest.php @@ -11,6 +11,7 @@ use Rapira\InetAddress; use Rapira\UnixAddress; use Rapira\Symfony\Internal\DispatcherRequestFactory; +use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Symfony\Component\HttpFoundation\File\UploadedFile; use Testo\Assert; use Testo\Expect; @@ -19,6 +20,8 @@ #[Test] final class DispatcherRequestFactoryTest { + use IsolatesProcessState; + public function convertsRequestWithoutMutatingGlobals(): void { $_SERVER = [ diff --git a/tests/Unit/DispatcherRunnerTest.php b/tests/Unit/DispatcherRunnerTest.php index d092652..607d228 100644 --- a/tests/Unit/DispatcherRunnerTest.php +++ b/tests/Unit/DispatcherRunnerTest.php @@ -14,6 +14,7 @@ use Rapira\Symfony\DispatcherRunner; use Rapira\Symfony\Internal\DispatcherRequestFactory; use Rapira\Symfony\Internal\ExchangeResponseEmitter; +use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Rapira\Symfony\Tests\Support\StubExchange; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -26,6 +27,8 @@ #[Test] final class DispatcherRunnerTest { + use IsolatesProcessState; + public function handlesSequentialFreshRequestsAndTerminatesBeforeReceivingNext(): void { $events = []; @@ -39,7 +42,7 @@ public function handlesSequentialFreshRequestsAndTerminatesBeforeReceivingNext() Assert::same($result, 0); Assert::same($kernel->paths, ['/one', '/two']); - Assert::false($kernel->requestIds[0] === $kernel->requestIds[1]); + Assert::false($kernel->requests[0] === $kernel->requests[1]); Assert::same($events, [ 'receive', 'handle:/one', 'terminate:/one', 'receive', 'handle:/two', 'terminate:/two', @@ -175,8 +178,8 @@ final class LifecycleKernel implements HttpKernelInterface, TerminableInterface /** @var list */ public array $paths = []; - /** @var list */ - public array $requestIds = []; + /** @var list */ + public array $requests = []; /** @var list */ public array $terminated = []; @@ -190,7 +193,7 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c { $path = $request->getPathInfo(); $this->paths[] = $path; - $this->requestIds[] = \spl_object_id($request); + $this->requests[] = $request; $this->events[] = 'handle:' . $path; return new Response($path); diff --git a/tests/Unit/ExchangeResponseEmitterTest.php b/tests/Unit/ExchangeResponseEmitterTest.php index 14fb2e6..fbdf426 100644 --- a/tests/Unit/ExchangeResponseEmitterTest.php +++ b/tests/Unit/ExchangeResponseEmitterTest.php @@ -9,6 +9,7 @@ use Rapira\InetAddress; use Rapira\Symfony\Internal\ExchangeResponseEmitter; use Rapira\Symfony\Internal\ResponseDiscardedException; +use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Rapira\Symfony\Tests\Support\StubExchange; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; @@ -22,6 +23,8 @@ #[Test] final class ExchangeResponseEmitterTest { + use IsolatesProcessState; + public function emitsOrdinaryResponseOnceWithRepeatedHeadersAndCookies(): void { $exchange = $this->exchange(); @@ -164,13 +167,14 @@ public function nonRemovableNestedBufferFailsWithoutHanging(): void } PHP); - $output = []; - $status = 0; - \exec('timeout 5 ' . \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($script) . ' 2>&1', $output, $status); - @\unlink($script); + try { + [$status, $output] = $this->runPhpScript($script); + } finally { + @\unlink($script); + } Assert::same($status, 0); - Assert::true(\str_contains(\implode("\n", $output), 'A nested output buffer cannot be removed safely.')); + Assert::true(\str_contains($output, 'A nested output buffer cannot be removed safely.')); } public function eventStreamResponseIsRejectedWhenAvailable(): void @@ -255,6 +259,55 @@ public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void @\unlink($path); } + /** + * @return array{int, string} + */ + private function runPhpScript(string $script): array + { + $process = \proc_open( + [\PHP_BINARY, $script], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + ); + if (!\is_resource($process)) { + throw new \RuntimeException('Unable to start the PHP subprocess.'); + } + + \fclose($pipes[0]); + \stream_set_blocking($pipes[1], false); + \stream_set_blocking($pipes[2], false); + $output = ''; + $exitCode = null; + $deadline = \microtime(true) + 5; + try { + do { + $output .= \stream_get_contents($pipes[1]); + $output .= \stream_get_contents($pipes[2]); + $status = \proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + break; + } + if (\microtime(true) >= $deadline) { + throw new \RuntimeException('PHP subprocess timed out.'); + } + \usleep(10_000); + } while (true); + + $output .= \stream_get_contents($pipes[1]); + $output .= \stream_get_contents($pipes[2]); + } finally { + if (\proc_get_status($process)['running']) { + \proc_terminate($process); + } + \fclose($pipes[1]); + \fclose($pipes[2]); + $closeStatus = \proc_close($process); + } + + return [$exitCode === -1 ? $closeStatus : $exitCode, $output]; + } + private function file(string $content): string { $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); diff --git a/tests/Unit/WorkerRunnerTest.php b/tests/Unit/WorkerRunnerTest.php index 45f0e86..4a5e270 100644 --- a/tests/Unit/WorkerRunnerTest.php +++ b/tests/Unit/WorkerRunnerTest.php @@ -5,6 +5,7 @@ namespace Rapira\Symfony\Tests\Unit; use Rapira\Symfony\Internal\RequestLoop; +use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Rapira\Symfony\WorkerRunner; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,6 +18,8 @@ #[Test] final class WorkerRunnerTest { + use IsolatesProcessState; + public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): void { $_SERVER = [ @@ -54,14 +57,17 @@ public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): ]); \ob_start(); - $result = (new WorkerRunner($kernel, $loop))->run(); - $output = (string) \ob_get_clean(); + try { + $result = (new WorkerRunner($kernel, $loop))->run(); + $output = (string) \ob_get_contents(); + } finally { + \ob_end_clean(); + } Assert::same($result, 0); Assert::same($output, 'response-1response-2'); Assert::same($kernel->handled, [ [ - 'id' => $kernel->handled[0]['id'], 'method' => 'GET', 'path' => '/first', 'query' => 'one', @@ -73,7 +79,6 @@ public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): 'boot_header' => null, ], [ - 'id' => $kernel->handled[1]['id'], 'method' => 'POST', 'path' => '/second', 'query' => 'two', @@ -85,7 +90,7 @@ public function servesSequentialRequestsWithFreshGlobalsAndOneResidentKernel(): 'boot_header' => null, ], ]); - Assert::false($kernel->handled[0]['id'] === $kernel->handled[1]['id']); + Assert::false($kernel->requests[0] === $kernel->requests[1]); Assert::same($loop->handlers, 2); } @@ -97,8 +102,11 @@ public function sendsThenTerminatesExactlyOncePerRequestAndDrains(): void $loop = new FakeRequestLoop([$this->requestGlobals('/lifecycle')], $events); \ob_start(); - $result = (new WorkerRunner($kernel, $loop))->run(); - \ob_end_clean(); + try { + $result = (new WorkerRunner($kernel, $loop))->run(); + } finally { + \ob_end_clean(); + } Assert::same($result, 0); Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate', 'drain']); @@ -208,10 +216,13 @@ class RecordingKernel implements HttpKernelInterface /** @var list> */ public array $handled = []; + /** @var list */ + public array $requests = []; + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response { + $this->requests[] = $request; $this->handled[] = [ - 'id' => \spl_object_id($request), 'method' => $request->getMethod(), 'path' => $request->getPathInfo(), 'query' => $request->query->get('q'), From 032e010f824feaca0a4714f55afbc78796bfac0b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 22:41:55 +0400 Subject: [PATCH 11/42] chore: align the development dependencies with the sibling bridges build: check the dependencies with composer-dependency-analyser chore: add support and funding metadata Assisted-By: Claude Opus 5.5 --- .php-cs-fixer.dist.php | 1 + composer-dependency-analyser.php | 14 ++++++++++++++ composer.json | 25 +++++++++++++++++++++++-- skills.json | 7 +++++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 composer-dependency-analyser.php create mode 100644 skills.json diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 4f7ae81..12f2572 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -8,5 +8,6 @@ ->include(__DIR__ . '/src') ->include(__DIR__ . '/tests') ->include(__DIR__ . '/testo.php') + ->include(__DIR__ . '/composer-dependency-analyser.php') ->include(__FILE__) ->build(); diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php new file mode 100644 index 0000000..01a247b --- /dev/null +++ b/composer-dependency-analyser.php @@ -0,0 +1,14 @@ +disableComposerAutoloadPathScan() + ->setFileExtensions(['php']) + ->addPathToScan(__DIR__ . '/src', isDev: false) + ->addPathToScan(__DIR__ . '/tests', isDev: true) + // Required through symfony/psr-http-message-bridge, whose PSR-7 conversion the runtime uses. + ->ignoreErrorsOnPackage('psr/http-message', [ErrorType::SHADOW_DEPENDENCY]); diff --git a/composer.json b/composer.json index 489dbb1..7d0303c 100644 --- a/composer.json +++ b/composer.json @@ -11,12 +11,22 @@ "worker" ], "homepage": "https://github.com/rapira-rs/symfony", + "support": { + "issues": "https://github.com/rapira-rs/symfony/issues", + "source": "https://github.com/rapira-rs/symfony" + }, "authors": [ { "name": "Aleksei Gagarin", "homepage": "https://github.com/roxblnfk" } ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], "require": { "php": ">=8.4", "nyholm/psr7": "^1.8", @@ -28,8 +38,15 @@ }, "require-dev": { "fidry/cpu-core-counter": "^1.2", + "llm/skills": "^1.14", + "rapira/testing": "^0.1.6", + "roxblnfk/unpoly": "^1.8", + "shipmonk/composer-dependency-analyser": "^1.8.4", "spiral/code-style": "^2.3.1", - "testo/testo": "^0.10.42", + "testo/assert": "^0.1.17", + "testo/lifecycle": "^0.1.9", + "testo/test": "^0.1.8", + "testo/testo": "^0.10.55", "vimeo/psalm": "^7" }, "autoload": { @@ -51,6 +68,7 @@ }, "config": { "allow-plugins": { + "llm/skills": true, "symfony/runtime": true }, "sort-packages": true @@ -58,8 +76,11 @@ "scripts": { "cs:diff": "php-cs-fixer fix --dry-run -v --diff", "cs:fix": "php-cs-fixer fix -v", + "dependency-analyser": "composer-dependency-analyser", "psalm": "psalm", "psalm:ci": "psalm --output-format=github --show-info=false --stats --threads=4", - "test": "testo" + "test": "testo", + "test:unit": "testo --suite=Unit", + "test:acceptance": "testo --suite=Acceptance" } } diff --git a/skills.json b/skills.json new file mode 100644 index 0000000..f891353 --- /dev/null +++ b/skills.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/roxblnfk/skills/master/resources/skills.schema.json", + "aliases": [ + ".claude/skills" + ], + "discovery": true +} From f0754f6008e821668c44e3e1c1de35ece29cff01 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 22:43:56 +0400 Subject: [PATCH 12/42] test: serve a fixture app through a live Rapira in every mode test: name the Runtime test file after its class chore: require ext-curl for the acceptance requests Assisted-By: Claude Opus 5.5 --- composer.json | 1 + testo.php | 20 +++++-- tests/Acceptance/ClassicModeTest.php | 27 ++++++++++ tests/Acceptance/DispatcherModeTest.php | 27 ++++++++++ tests/Acceptance/Support/ServerRequests.php | 53 +++++++++++++++++++ tests/Acceptance/WorkerModeTest.php | 27 ++++++++++ tests/App/Kernel.php | 25 +++++++++ tests/App/public/index.php | 13 +++++ tests/App/rapira.toml | 7 +++ .../{ARuntimeTest.php => RuntimeTest.php} | 0 10 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/Acceptance/ClassicModeTest.php create mode 100644 tests/Acceptance/DispatcherModeTest.php create mode 100644 tests/Acceptance/Support/ServerRequests.php create mode 100644 tests/Acceptance/WorkerModeTest.php create mode 100644 tests/App/Kernel.php create mode 100644 tests/App/public/index.php create mode 100644 tests/App/rapira.toml rename tests/Unit/{ARuntimeTest.php => RuntimeTest.php} (100%) diff --git a/composer.json b/composer.json index 7d0303c..30e4fca 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "symfony/runtime": "^7.4 || ^8.0" }, "require-dev": { + "ext-curl": "*", "fidry/cpu-core-counter": "^1.2", "llm/skills": "^1.14", "rapira/testing": "^0.1.6", diff --git a/testo.php b/testo.php index 886ab86..d0f63d7 100644 --- a/testo.php +++ b/testo.php @@ -2,16 +2,28 @@ declare(strict_types=1); +use Rapira\Sdk\Testing\Testo\RunRapiraPlugin; use Testo\Application\Config\ApplicationConfig; -use Testo\Application\Config\FinderConfig; +use Testo\Application\Config\Plugin\SuitePlugins; use Testo\Application\Config\SuiteConfig; +$projectRoot = __DIR__; +// The rapira binary (and its bundled PHP) is fetched into `runtime/bin` on demand when missing. +$rapiraBinary = $projectRoot . '/runtime/bin/rapira' . (\DIRECTORY_SEPARATOR === '\\' ? '.exe' : ''); + return new ApplicationConfig( - src: new FinderConfig(['src']), + src: ['src'], suites: [ + new SuiteConfig(name: 'Unit', location: ['tests/Unit']), new SuiteConfig( - name: 'Unit', - location: new FinderConfig([__DIR__ . '/tests/Unit']), + name: 'Acceptance', + location: ['tests/Acceptance'], + plugins: SuitePlugins::with( + new RunRapiraPlugin( + binary: $rapiraBinary, + workingDirectory: $projectRoot . '/tests/App', + ), + ), ), ], ); diff --git a/tests/Acceptance/ClassicModeTest.php b/tests/Acceptance/ClassicModeTest.php new file mode 100644 index 0000000..1aee7dd --- /dev/null +++ b/tests/Acceptance/ClassicModeTest.php @@ -0,0 +1,27 @@ +request('/'); + + Assert::same($status, 200); + Assert::same($body, 'OK'); + } + + public function processRunsInTheRequestedMode(): void + { + [, $body] = $this->request('/mode'); + + Assert::same($body, $this->mode()->value); + } + + abstract protected function mode(): Mode; + + /** + * @return array{0: int, 1: string} The response status code and body. + */ + private function request(string $path): array + { + $curl = \curl_init(); + \curl_setopt_array($curl, [ + \CURLOPT_URL => 'http://' . self::ADDRESS . $path, + \CURLOPT_RETURNTRANSFER => true, + \CURLOPT_TIMEOUT => 10, + ]); + + $body = \curl_exec($curl); + if (!\is_string($body)) { + throw new \RuntimeException(\sprintf('Request to %s failed: %s', $path, \curl_error($curl))); + } + + return [(int) \curl_getinfo($curl, \CURLINFO_HTTP_CODE), $body]; + } +} diff --git a/tests/Acceptance/WorkerModeTest.php b/tests/Acceptance/WorkerModeTest.php new file mode 100644 index 0000000..68fb921 --- /dev/null +++ b/tests/Acceptance/WorkerModeTest.php @@ -0,0 +1,27 @@ +getPathInfo()) { + '/' => new Response('OK'), + '/mode' => new Response(\strtolower(\Rapira\get_mode()->name)), + default => new Response('Not Found', Response::HTTP_NOT_FOUND), + }; + } +} diff --git a/tests/App/public/index.php b/tests/App/public/index.php new file mode 100644 index 0000000..4a25140 --- /dev/null +++ b/tests/App/public/index.php @@ -0,0 +1,13 @@ + new Kernel(); diff --git a/tests/App/rapira.toml b/tests/App/rapira.toml new file mode 100644 index 0000000..d64e33e --- /dev/null +++ b/tests/App/rapira.toml @@ -0,0 +1,7 @@ +[http] +listen = "127.0.0.1:8080" + +[http.pool] +entrypoint = "public/index.php" +# "dispatcher", "worker" or "classic": the same public/index.php serves all three. +mode = "worker" diff --git a/tests/Unit/ARuntimeTest.php b/tests/Unit/RuntimeTest.php similarity index 100% rename from tests/Unit/ARuntimeTest.php rename to tests/Unit/RuntimeTest.php From 0764710928a787f7f90c9be3c73694d22962c007 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 22:44:12 +0400 Subject: [PATCH 13/42] chore: add code owners, git attributes and contribution templates chore: request updates from Dependabot chore: align the editor config and ignore list with the sibling bridges Assisted-By: Claude Opus 5.5 --- .editorconfig | 11 ++++++++++- .gitattributes | 15 +++++++++++++++ .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE.md | 15 +++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 6 ++++++ .github/dependabot.yml | 22 ++++++++++++++++++++++ .gitignore | 2 ++ 7 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml diff --git a/.editorconfig b/.editorconfig index faa062e..187eb38 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,8 +8,17 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true -[*.{yaml,yml}] +[*.yaml] indent_size = 2 +[*.yml] +indent_size = 2 + +[*.http] +end_of_line = crlf + [*.md] trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..697dba9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +# PHP-aware hunk headers in `git diff`. +*.php diff=php + +# Keep the published package lean: nothing ships in `composer archive` unless +# re-included below. +* export-ignore + +/composer.json -export-ignore +/README.md -export-ignore +/CHANGELOG.md -export-ignore +/LICENSE.md -export-ignore +/src -export-ignore +/src/** -export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c711bb9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @rapira-rs/symfony diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..9817d0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +### What steps will reproduce the problem? + +### What is the expected result? + +### What do you get instead? + +### Additional info + +| Q | A +| ---------------- | --- +| Package version | 0.1.? +| Rapira version | +| Symfony version | +| PHP version | +| Operating system | diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cecccf6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,6 @@ +| Q | A +| ------------- | --- +| Is bugfix? | ✔️/❌ +| New feature? | ✔️/❌ +| Breaks BC? | ✔️/❌ +| Fixed issues | diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..11540de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: [ "*" ] + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + composer-dev-dependencies: + dependency-type: "development" + versioning-strategy: "widen" diff --git a/.gitignore b/.gitignore index 8b70909..a7f8480 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ /runtime/ /vendor/ /composer.lock +/.env *.log +/coverage.xml From d5357df7036e486f5935341c90dfb553f1e17561 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 22:44:35 +0400 Subject: [PATCH 14/42] ci: release with release-please chore: alias the 1.x branch as 0.1.x-dev The first release is 0.1.0, as in rapira/laravel, so the 1.x branch has to resolve as 0.1.x-dev for anything requiring ^0.1. Assisted-By: Claude Opus 5.5 --- .github/release-please/config.json | 84 ++++++++++++++++++++++++++++ .github/release-please/version.json | 1 + .github/workflows/create-release.yml | 40 +++++++++++++ .github/workflows/quality.yml | 2 +- .github/workflows/testing.yml | 2 +- CHANGELOG.md | 1 + composer.json | 2 +- 7 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 .github/release-please/config.json create mode 100644 .github/release-please/version.json create mode 100644 .github/workflows/create-release.yml create mode 100644 CHANGELOG.md diff --git a/.github/release-please/config.json b/.github/release-please/config.json new file mode 100644 index 0000000..6ac120c --- /dev/null +++ b/.github/release-please/config.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "php", + "pull-request-title-pattern": "chore: release ${version}", + "packages": { + ".": { + "package-name": "rapira/symfony", + "changelog-path": "CHANGELOG.md" + } + }, + "bump-patch-for-minor-pre-major": true, + "bump-minor-pre-major": true, + "initial-version": "0.1.0", + "include-component-in-tag": false, + "include-v-in-tag": false, + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "Performance Improvements", + "hidden": false + }, + { + "type": "docs", + "section": "Documentation", + "hidden": false + }, + { + "type": "deps", + "section": "Dependencies", + "hidden": false + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": false + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "tests", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System", + "hidden": true + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + }, + { + "type": "chore", + "section": "Miscellaneous", + "hidden": true + }, + { + "type": "style", + "section": "Styles", + "hidden": true + }, + { + "type": "revert", + "section": "Reverts", + "hidden": true + } + ] +} diff --git a/.github/release-please/version.json b/.github/release-please/version.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.github/release-please/version.json @@ -0,0 +1 @@ +{} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..60bcb61 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,40 @@ +--- + +# https://github.com/googleapis/release-please/blob/main/docs/customizing.md +# https://github.com/googleapis/release-please-action + +on: # yamllint disable-line rule:truthy + push: + branches: + - 1.x + # Manual re-trigger, mirroring release-please's own post-merge run. + workflow_dispatch: + +name: 📦 Create release + +# Serialize release runs so a release-creation run is never interrupted or raced +# by another (e.g. a feature PR merged right before the release PR). +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: 🎉 Create release + uses: googleapis/release-please-action@v5 + with: + # Events produced with GITHUB_TOKEN do not trigger other workflows, so + # CI won't run on the release PR itself. Swap for a PAT / GitHub App + # token if the release PR needs the full CI cycle. + token: ${{ secrets.GITHUB_TOKEN }} + config-file: .github/release-please/config.json + manifest-file: .github/release-please/version.json + target-branch: 1.x + +... diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1a403a9..95015b5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 5 runs-on: ubuntu-latest env: - COMPOSER_ROOT_VERSION: 1.x-dev + COMPOSER_ROOT_VERSION: '0.1.x-dev' steps: - name: Check out the codebase uses: actions/checkout@v6 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6f7a82f..5fde7a7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 5 runs-on: ubuntu-latest env: - COMPOSER_ROOT_VERSION: 1.x-dev + COMPOSER_ROOT_VERSION: '0.1.x-dev' strategy: fail-fast: false matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/composer.json b/composer.json index 30e4fca..e383b47 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "0.1.x-dev" } }, "config": { From 9d87d8fe13719bc0c7e5d5d9cfdcff703a5e36a1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 22:46:58 +0400 Subject: [PATCH 15/42] ci: test on Linux and Windows against a live Rapira ci: run the dependency analyser alongside code style and static analysis ci: audit the dependencies in a separate security workflow ci: restrict the workflow token to read access Assisted-By: Claude Opus 5.5 --- .github/workflows/quality.yml | 59 ++++++++++++++++++++++++++++------ .github/workflows/security.yml | 52 ++++++++++++++++++++++++++++++ .github/workflows/testing.yml | 59 ++++++++++++++++++++++++++-------- 3 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 95015b5..744c473 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,41 +1,80 @@ --- -name: Quality and security +name: 🧹 Quality -on: +on: # yamllint disable-line rule:truthy pull_request: + branches: + - 1.x push: branches: - 1.x +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: quality: + name: Code style and static analysis timeout-minutes: 5 runs-on: ubuntu-latest env: COMPOSER_ROOT_VERSION: '0.1.x-dev' steps: - - name: Check out the codebase + - name: 📦 Check out the codebase uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Setup PHP + - name: 🛠️ Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.4' ini-values: error_reporting=E_ALL coverage: none - - name: Validate composer metadata + - name: 🤖 Validate composer.json run: composer validate --ansi --strict - - name: Install dependencies + - name: 📥 Install dependencies with composer uses: ramsey/composer-install@v3 - - name: Check code style + - name: 🎨 Check code style run: composer cs:diff - - name: Run static analysis + - name: 🔍 Run static analysis run: composer psalm:ci - - name: Audit dependencies - run: composer audit --ansi + dependency-analysis: + name: Dependency analysis on PHP ${{ matrix.php-version }} + timeout-minutes: 5 + runs-on: ubuntu-latest + env: + COMPOSER_ROOT_VERSION: '0.1.x-dev' + strategy: + fail-fast: false + matrix: + php-version: + - '8.4' + - '8.5' + steps: + - name: 📦 Check out the codebase + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: 🛠️ Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: 📥 Install dependencies with composer + uses: ramsey/composer-install@v3 + + - name: 🧩 Check dependencies + run: composer dependency-analyser diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..f8aabcc --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,52 @@ +--- + +name: 🔐 Security analysis + +on: # yamllint disable-line rule:truthy + pull_request: + push: + +permissions: + contents: read + +jobs: + security-analysis: + timeout-minutes: 4 + runs-on: ${{ matrix.os }} + env: + COMPOSER_ROOT_VERSION: '0.1.x-dev' + concurrency: + cancel-in-progress: true + group: security-analysis-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + strategy: + fail-fast: true + matrix: + os: + - ubuntu-latest + php-version: + - '8.4' + dependencies: + - highest + steps: + - name: 📦 Check out the codebase + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: 🛠️ Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + ini-values: error_reporting=E_ALL + coverage: none + + - name: 🤖 Validate composer.json + run: composer validate --ansi --strict + + - name: 📥 Install dependencies with composer + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: 🐛 Check installed packages for security vulnerability advisories + run: composer audit --ansi diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5fde7a7..0de21de 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,47 +1,78 @@ --- -name: Unit testing +name: 🧪 Testing -on: +on: # yamllint disable-line rule:truthy pull_request: + branches: + - 1.x push: branches: - 1.x +permissions: + contents: read + +env: + # The acceptance suite fetches the rapira binary through dload; the cached registry spares its API calls. + DLOAD_CACHE_DIR: ${{ github.workspace }}/runtime/dload-cache + jobs: testing: - timeout-minutes: 5 - runs-on: ubuntu-latest + name: PHP ${{ matrix.php-version }} on ${{ matrix.os }} (${{ matrix.dependencies }}) + timeout-minutes: 10 + runs-on: ${{ matrix.os }} env: COMPOSER_ROOT_VERSION: '0.1.x-dev' + concurrency: + cancel-in-progress: true + group: testing-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.os }}-${{ matrix.php-version }}-${{ matrix.dependencies }} strategy: fail-fast: false matrix: + os: + - ubuntu-latest + - windows-latest + php-version: + - '8.4' + - '8.5' + dependencies: + - highest include: - - php-version: '8.4' + - os: ubuntu-latest + php-version: '8.4' dependencies: lowest - - php-version: '8.4' - dependencies: highest - - php-version: '8.5' - dependencies: highest steps: - - name: Check out the codebase + - name: 📦 Check out the codebase uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Setup PHP + - name: 🛠️ Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + extensions: curl, mbstring ini-values: error_reporting=E_ALL coverage: none - - name: Validate composer metadata + - name: 🤖 Validate composer.json run: composer validate --ansi --strict - - name: Install dependencies + - name: 📥 Install dependencies with composer uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} - - name: Run unit tests + # A GitHub cache entry is immutable, so the run_id keeps the key missing and restore-keys reads the newest. + - name: 💾 Restore the dload version registry + uses: actions/cache@v4 + with: + path: runtime/dload-cache + key: dload-registry-${{ github.run_id }} + restore-keys: dload-registry- + + - name: 🧪 Run tests run: composer test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f0e9db32d03c076e4fa952be681966e1cf7e51ca Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 23:20:09 +0400 Subject: [PATCH 16/42] test: drive the bridge through the Runtime with the SDK doubles The unit tests reached the runners through protected Runtime hooks, the RequestLoop interface and constructor injection, so the planned refactor of src could not remove those seams without rewriting the tests. The Feature suite builds the Runtime the way autoload_runtime.php does, scripts the host with FakeRuntime, FakeHttpDispatcher and FakeExchange from rapira/testing, and observes only what a host or an application can see. The dispatcher test that bundled three scenarios stopped at its first expected exception, so the check that unexpected kernel errors propagate never ran; each scenario is a test of its own now. Assisted-By: Claude Opus 5.5 --- composer.json | 2 +- testo.php | 2 +- tests/Feature/DispatcherModeTest.php | 178 ++++++++++ .../DispatcherRequestTest.php} | 246 ++++++++----- tests/Feature/DispatcherResponseTest.php | 311 ++++++++++++++++ tests/Feature/RuntimeTest.php | 98 +++++ tests/Feature/WorkerModeTest.php | 150 ++++++++ tests/Support/FakeRuntimeLifecycle.php | 43 +++ tests/Support/ForeignDispatcher.php | 39 ++ tests/Support/IsolatesProcessState.php | 59 --- tests/Support/NullKernel.php | 17 - tests/Support/RuntimeForMode.php | 30 -- tests/Support/ScriptedExchange.php | 85 +++++ tests/Support/StubExchange.php | 75 ---- tests/Support/StubHttpDispatcher.php | 33 -- tests/Support/TestKernel.php | 52 +++ tests/Unit/DispatcherRunnerTest.php | 250 ------------- tests/Unit/ExchangeResponseEmitterTest.php | 335 ------------------ tests/Unit/RuntimeTest.php | 64 ---- tests/Unit/WorkerRunnerTest.php | 318 ----------------- 20 files changed, 1107 insertions(+), 1280 deletions(-) create mode 100644 tests/Feature/DispatcherModeTest.php rename tests/{Unit/DispatcherRequestFactoryTest.php => Feature/DispatcherRequestTest.php} (56%) create mode 100644 tests/Feature/DispatcherResponseTest.php create mode 100644 tests/Feature/RuntimeTest.php create mode 100644 tests/Feature/WorkerModeTest.php create mode 100644 tests/Support/FakeRuntimeLifecycle.php create mode 100644 tests/Support/ForeignDispatcher.php delete mode 100644 tests/Support/IsolatesProcessState.php delete mode 100644 tests/Support/NullKernel.php delete mode 100644 tests/Support/RuntimeForMode.php create mode 100644 tests/Support/ScriptedExchange.php delete mode 100644 tests/Support/StubExchange.php delete mode 100644 tests/Support/StubHttpDispatcher.php create mode 100644 tests/Support/TestKernel.php delete mode 100644 tests/Unit/DispatcherRunnerTest.php delete mode 100644 tests/Unit/ExchangeResponseEmitterTest.php delete mode 100644 tests/Unit/RuntimeTest.php delete mode 100644 tests/Unit/WorkerRunnerTest.php diff --git a/composer.json b/composer.json index e383b47..829ff83 100644 --- a/composer.json +++ b/composer.json @@ -81,7 +81,7 @@ "psalm": "psalm", "psalm:ci": "psalm --output-format=github --show-info=false --stats --threads=4", "test": "testo", - "test:unit": "testo --suite=Unit", + "test:feature": "testo --suite=Feature", "test:acceptance": "testo --suite=Acceptance" } } diff --git a/testo.php b/testo.php index d0f63d7..74aab27 100644 --- a/testo.php +++ b/testo.php @@ -14,7 +14,7 @@ return new ApplicationConfig( src: ['src'], suites: [ - new SuiteConfig(name: 'Unit', location: ['tests/Unit']), + new SuiteConfig(name: 'Feature', location: ['tests/Feature']), new SuiteConfig( name: 'Acceptance', location: ['tests/Acceptance'], diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php new file mode 100644 index 0000000..25be7a3 --- /dev/null +++ b/tests/Feature/DispatcherModeTest.php @@ -0,0 +1,178 @@ +beforeReceive = static function () use ($kernel): void { + $kernel->events[] = 'receive'; + }; + + $this->serve($kernel, $dispatcher); + + Assert::same($kernel->events, [ + 'receive', 'handle:/one', 'terminate:/one', + 'receive', 'handle:/two', 'terminate:/two', + 'receive', + ]); + Assert::same($first->getBody(), '/one'); + Assert::same($second->getBody(), '/two'); + Assert::true($first->isFinalized()); + Assert::true($second->isFinalized()); + Assert::notSame($kernel->requests[0], $kernel->requests[1]); + } + + public function cancelledExchangeIsSkipped(): void + { + $cancelled = FakeExchange::for('/cancelled'); + $cancelled->discard(); + $healthy = FakeExchange::for('/healthy'); + $kernel = new TestKernel(); + + $this->serve($kernel, new FakeHttpDispatcher($cancelled, $healthy)); + + Assert::same($kernel->events, ['handle:/healthy', 'terminate:/healthy']); + Assert::null($cancelled->status); + Assert::true($healthy->isFinalized()); + } + + public function exchangeClosedByTheHostMidResponseStillTerminatesAndTheLoopGoesOn(): void + { + $gone = FakeExchange::for('/gone'); + $gone->discardOnWrite(); + $healthy = FakeExchange::for('/healthy'); + $kernel = new TestKernel(); + + $this->serve($kernel, new FakeHttpDispatcher($gone, $healthy)); + + Assert::same($kernel->events, ['handle:/gone', 'terminate:/gone', 'handle:/healthy', 'terminate:/healthy']); + Assert::true($healthy->isFinalized()); + } + + public function discardRaisedByTheApplicationItselfPropagates(): never + { + $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/terminate-fail'), FakeExchange::for('/must-not-run')); + $kernel = new TestKernel( + terminate: static fn() => throw new WorkDiscardedException('application terminate discard'), + ); + + Expect::exception(WorkDiscardedException::class)->withMessage('application terminate discard'); + try { + $this->serve($kernel, $dispatcher); + } finally { + Assert::same($dispatcher->receives, 1); + } + } + + public function kernelFailurePropagatesWithoutServingTheNextExchange(): never + { + $next = FakeExchange::for('/must-not-run'); + $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/fail'), $next); + $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); + + Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); + try { + $this->serve($kernel, $dispatcher); + } finally { + Assert::same($dispatcher->receives, 1); + Assert::null($next->status); + } + } + + public function exchangeIsReleasedBeforeTheNextReceive(): void + { + $exchange = FakeExchange::for('/'); + $reference = \WeakReference::create($exchange); + $dispatcher = new FakeHttpDispatcher($exchange); + unset($exchange); + $released = null; + $dispatcher->beforeReceive = static function () use ($dispatcher, $reference, &$released): void { + $dispatcher->receives === 2 and $released = $reference->get() === null; + }; + + $this->serve(new TestKernel(), $dispatcher); + + Assert::true($released); + } + + public function refusesADispatcherOfAnotherPlugin(): never + { + (new FakeRuntime(Mode::Dispatcher, new ForeignDispatcher()))->install(); + + Expect::exception(\LogicException::class) + ->withMessage('Rapira Dispatcher mode requires an HTTP dispatcher; got ' . ForeignDispatcher::class . '.'); + + self::runtime()->getRunner(new TestKernel()); + } + + /** + * Holds whether the loop survives the rejection or not. + */ + public function terminalInterimStatusNeverReachesTheClient(): void + { + $exchange = new ScriptedExchange(FakeExchange::for('/')); + $kernel = new TestKernel(static fn(): Response => new Response('', 103)); + + try { + $this->serve($kernel, new FakeHttpDispatcher($exchange)); + } catch (\LogicException) { + } + + Assert::false(\in_array(103, $exchange->heads, true)); + } + + /** + * Holds whether the loop survives the rejection or not. + */ + public function eventStreamResponseIsNeverStreamed(): void + { + $exchange = new ScriptedExchange(FakeExchange::for('/')); + $kernel = new TestKernel( + static fn(): Response => new EventStreamResponse(static fn(): iterable => yield 'event'), + ); + + try { + $this->serve($kernel, new FakeHttpDispatcher($exchange)); + } catch (\LogicException) { + } + + Assert::false(\in_array(200, $exchange->heads, true)); + Assert::string($exchange->exchange->getBody())->notContains('event'); + } + + private function serve(TestKernel $kernel, FakeHttpDispatcher $dispatcher): void + { + (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); + + Assert::same(self::runtime()->getRunner($kernel)->run(), 0); + } +} diff --git a/tests/Unit/DispatcherRequestFactoryTest.php b/tests/Feature/DispatcherRequestTest.php similarity index 56% rename from tests/Unit/DispatcherRequestFactoryTest.php rename to tests/Feature/DispatcherRequestTest.php index 974a527..1a032d4 100644 --- a/tests/Unit/DispatcherRequestFactoryTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -2,30 +2,37 @@ declare(strict_types=1); -namespace Rapira\Symfony\Tests\Unit; +namespace Rapira\Symfony\Tests\Feature; use Rapira\Http\FormField; use Rapira\Http\Multipart; use Rapira\Http\Request as RapiraRequest; use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; +use Rapira\Mode; +use Rapira\Sdk\Testing\Double\FakeRuntime; +use Rapira\Sdk\Testing\Double\Http\FakeExchange; +use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; +use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; +use Rapira\Symfony\Tests\Support\TestKernel; use Rapira\UnixAddress; -use Rapira\Symfony\Internal\DispatcherRequestFactory; -use Rapira\Symfony\Tests\Support\IsolatesProcessState; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Testo\Assert; -use Testo\Expect; use Testo\Test; +/** + * The Symfony request an application sees for an exchange in {@see Mode::Dispatcher}. + */ #[Test] -final class DispatcherRequestFactoryTest +final class DispatcherRequestTest { - use IsolatesProcessState; + use FakeRuntimeLifecycle; - public function convertsRequestWithoutMutatingGlobals(): void + public function requestDataReachesTheApplicationWithoutTouchingTheGlobals(): void { $_SERVER = [ - 'APP_ENV' => 'test', 'APP_CUSTOM_SETTING' => 'kept', 'SCRIPT_FILENAME' => '/app/public/index.php', 'HTTP_BOOT_ONLY' => 'no', @@ -41,15 +48,12 @@ public function convertsRequestWithoutMutatingGlobals(): void 'SERVER_PORT' => 2, 'CONTENT_TYPE' => 'text/boot', 'CONTENT_LENGTH' => '999', - ]; + ] + $_SERVER; $_GET = ['global' => 'unchanged']; $_POST = ['global' => 'unchanged']; $_COOKIE = ['global' => 'unchanged']; $_FILES = ['global' => 'unchanged']; - $globals = [$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES]; - - $factory = new DispatcherRequestFactory($_SERVER); - $converted = $factory->create(self::request( + $exchange = new FakeExchange(self::request( method: 'POST', uri: 'https://example.test/path?q=one&q=two', target: '/path?q=one&q=two&raw=%2F', @@ -63,9 +67,10 @@ public function convertsRequestWithoutMutatingGlobals(): void 'Content-Type' => ['application/json'], 'Content-Length' => ['20'], ], - body: "{\"binary\":\"\\u0000\"}", + body: '{"binary":"\u0000"}', )); - $request = $converted->request; + + [$request, $globals] = $this->serveWithGlobals($exchange); Assert::same($request->getMethod(), 'POST'); Assert::same($request->server->get('RAPIRA_REQUEST_METHOD'), 'POST'); @@ -83,7 +88,7 @@ public function convertsRequestWithoutMutatingGlobals(): void Assert::same($request->server->get('CONTENT_LENGTH'), '20'); Assert::same($request->headers->all('x-repeat'), ['one', 'two']); Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); - Assert::same($request->getContent(), "{\"binary\":\"\\u0000\"}"); + Assert::same($request->getContent(), '{"binary":"\u0000"}'); Assert::same($request->server->get('APP_ENV'), 'test'); Assert::same($request->server->get('APP_CUSTOM_SETTING'), 'kept'); Assert::same($request->server->get('SCRIPT_FILENAME'), '/app/public/index.php'); @@ -91,10 +96,9 @@ public function convertsRequestWithoutMutatingGlobals(): void Assert::same([$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES], $globals); } - public function omitsStaleBootRequestMetadataAndKeepsPhysicalListener(): void + public function requestMetadataOfTheBootIsNotInheritedAndTheListenerIsKept(): void { - $boot = [ - 'APP_ENV' => 'test', + $_SERVER = [ 'AUTH_TYPE' => 'Basic', 'CONTENT_LENGTH' => '999', 'CONTENT_TYPE' => 'text/boot', @@ -105,15 +109,22 @@ public function omitsStaleBootRequestMetadataAndKeepsPhysicalListener(): void 'SERVER_ADDR' => '192.0.2.2', 'SERVER_PORT' => 2, 'SERVER_PROTOCOL' => 'HTTP/0.9', - ]; - $factory = new DispatcherRequestFactory($boot); + ] + $_SERVER; + $kernel = new TestKernel(); - $internet = $factory->create(self::request( + $this->serve($kernel, new FakeExchange(self::request( uri: 'http://public.example:9443/plain', target: '/plain', remote: new UnixAddress(null), server: new InetAddress('10.0.0.8', 8443), - ))->request; + )), new FakeExchange(self::request( + uri: 'http://localhost/unix', + target: '/unix', + remote: new UnixAddress(null), + server: new UnixAddress('/run/rapira.sock'), + ))); + + [$internet, $unix] = $kernel->requests; Assert::same($internet->server->get('SERVER_ADDR'), '10.0.0.8'); Assert::same($internet->server->get('SERVER_PORT'), 8443); Assert::same($internet->server->get('SERVER_PROTOCOL'), 'HTTP/1.1'); @@ -121,125 +132,114 @@ public function omitsStaleBootRequestMetadataAndKeepsPhysicalListener(): void foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'REMOTE_ADDR', 'REMOTE_PORT', 'REQUEST_SCHEME'] as $key) { Assert::false($internet->server->has($key), $key); } - - $unix = $factory->create(self::request( - uri: 'http://localhost/unix', - target: '/unix', - remote: new UnixAddress(null), - server: new UnixAddress('/run/rapira.sock'), - ))->request; - Assert::false($unix->server->has('SERVER_ADDR')); - Assert::false($unix->server->has('SERVER_PORT')); - Assert::false($unix->server->has('REMOTE_ADDR')); - Assert::false($unix->server->has('REMOTE_PORT')); - Assert::false($unix->server->has('HTTPS')); - Assert::false($unix->server->has('CONTENT_TYPE')); - Assert::false($unix->server->has('CONTENT_LENGTH')); + foreach (['SERVER_ADDR', 'SERVER_PORT', 'REMOTE_ADDR', 'REMOTE_PORT', 'HTTPS', 'CONTENT_TYPE', 'CONTENT_LENGTH'] as $key) { + Assert::false($unix->server->has($key), $key); + } } - public function preservesUrlEncodedAndMultipartNestedDataAndCleansUploadCopies(): void + public function urlEncodedFormIsParsedIntoNestedFields(): void { - $form = (new DispatcherRequestFactory([]))->create(self::request( - method: 'POST', - headers: ['Content-Type' => ['application/x-www-form-urlencoded']], - body: 'user[name]=Ada&items[]=one&items[]=two', - ))->request; - Assert::same($form->request->all(), [ + $request = $this->serveOne(FakeExchange::for( + '/form', + 'POST', + ['content-type' => ['application/x-www-form-urlencoded']], + 'user[name]=Ada&items[]=one&items[]=two', + )); + + Assert::same($request->request->all(), [ 'user' => ['name' => 'Ada'], 'items' => ['one', 'two'], ]); + } - $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); - \file_put_contents($source, 'upload-body'); - $multipart = new Multipart( + public function multipartFieldsAndFilesKeepTheirNestingAndUploadCopiesAreRemovedAfterwards(): void + { + $source = self::file('upload-body'); + $seen = []; + $kernel = new TestKernel(static function (Request $request) use (&$seen): Response { + $file = $request->files->all()['files']['docs'][0]; + Assert::true($file instanceof UploadedFile); + $seen = [ + 'fields' => $request->request->all(), + 'name' => $file->getClientOriginalName(), + 'content' => \file_get_contents($file->getPathname()), + 'path' => $file->getPathname(), + 'empty' => $request->files->all()['empty']->getError(), + ]; + + return new Response(); + }); + + $this->serve($kernel, FakeExchange::for('/upload', 'POST', body: new Multipart( [new FormField('meta[name]', 'Ada', [])], [ new RapiraUploadedFile('files[docs][]', 'note.txt', 'text/plain', [], $source, 11), new RapiraUploadedFile('empty', '', null, [], $source, 0), ], - ); - $converted = (new DispatcherRequestFactory([]))->create(self::request( - method: 'POST', - body: $multipart, - )); - $request = $converted->request; - - Assert::same($request->request->all(), ['meta' => ['name' => 'Ada']]); - $file = $request->files->all()['files']['docs'][0]; - Assert::true($file instanceof UploadedFile); - Assert::same($file->getClientOriginalName(), 'note.txt'); - Assert::same(\file_get_contents($file->getPathname()), 'upload-body'); - Assert::same($request->files->all()['empty']->getError(), \UPLOAD_ERR_NO_FILE); - $copy = $file->getPathname(); - Assert::true(\is_file($copy)); - - $converted->cleanup(); + ))); - Assert::false(\is_file($copy)); + Assert::same($seen['fields'], ['meta' => ['name' => 'Ada']]); + Assert::same($seen['name'], 'note.txt'); + Assert::same($seen['content'], 'upload-body'); + Assert::same($seen['empty'], \UPLOAD_ERR_NO_FILE); + Assert::false(\is_file($seen['path'])); Assert::true(\is_file($source)); @\unlink($source); } - public function rejectsUnreadableNamedUploadWithoutCreatingTemporaryFiles(): void + /** + * Holds whether the failure stops the loop or is answered. + */ + public function unreadableUploadLeavesNoTemporaryCopies(): void { - $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); - \file_put_contents($source, 'first'); - $before = self::uploadTempPaths(); + $source = self::file('first'); + $before = self::uploadCopies(); $missing = \sys_get_temp_dir() . '/rapira-missing-' . \bin2hex(\random_bytes(8)); - $multipart = new Multipart([], [ + $exchange = FakeExchange::for('/upload', 'POST', body: new Multipart([], [ new RapiraUploadedFile('first', 'first.txt', 'text/plain', [], $source, 5), new RapiraUploadedFile('empty', '', null, [], $missing, 0), new RapiraUploadedFile('file', 'named.txt', 'text/plain', [], $missing, 10), - ]); + ])); + $kernel = new TestKernel(); - Expect::exception(\RuntimeException::class) - ->withMessage(\sprintf('Unable to read Rapira upload "%s".', $missing)); try { - (new DispatcherRequestFactory([]))->create(self::request(body: $multipart)); + $this->serve($kernel, $exchange); + } catch (\RuntimeException $exception) { + Assert::same($exception->getMessage(), \sprintf('Unable to read Rapira upload "%s".', $missing)); } finally { - Assert::same(self::uploadTempPaths(), $before); @\unlink($source); } + + Assert::same($kernel->requests, []); + Assert::same(self::uploadCopies(), $before); } - public function leavesMovedUploadAliveAfterCleanup(): void + public function uploadMovedByTheApplicationSurvivesTheCleanup(): void { - $source = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); - \file_put_contents($source, 'keep'); - $multipart = new Multipart([], [ - new RapiraUploadedFile('file', 'file.txt', 'text/plain', [], $source, 4), - ]); - $converted = (new DispatcherRequestFactory([]))->create(self::request(body: $multipart)); + $source = self::file('keep'); $destination = \tempnam(\sys_get_temp_dir(), 'rapira-moved-'); @\unlink($destination); - $converted->request->files->get('file')->move(\dirname($destination), \basename($destination)); + $kernel = new TestKernel(static function (Request $request) use ($destination): Response { + $request->files->get('file')->move(\dirname($destination), \basename($destination)); - $converted->cleanup(); + return new Response(); + }); + + $this->serve($kernel, FakeExchange::for('/upload', 'POST', body: new Multipart([], [ + new RapiraUploadedFile('file', 'file.txt', 'text/plain', [], $source, 4), + ]))); Assert::same(\file_get_contents($destination), 'keep'); @\unlink($destination); @\unlink($source); } - /** - * @return list - */ - private static function uploadTempPaths(): array - { - $paths = \glob(\sys_get_temp_dir() . '/rapira-upload-*') ?: []; - \sort($paths); - - return $paths; - } - /** * @param non-empty-string $method * @param non-empty-string $uri * @param non-empty-string $target * @param non-empty-string $protocol * @param array> $headers - * @param InetAddress|UnixAddress $remote - * @param InetAddress|UnixAddress $server */ private static function request( string $method = 'GET', @@ -265,4 +265,56 @@ private static function request( 1_700_000_000.5, ); } + + private static function file(string $content): string + { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-source-'); + \file_put_contents($path, $content); + + return $path; + } + + /** + * @return list + */ + private static function uploadCopies(): array + { + $paths = \glob(\sys_get_temp_dir() . '/rapira-upload-*') ?: []; + \sort($paths); + + return $paths; + } + + /** + * Serves one exchange and returns the request the application saw, with the superglobals as they were + * just before the loop started. + * + * @return array{Request, list>} + */ + private function serveWithGlobals(FakeExchange $exchange): array + { + $kernel = new TestKernel(); + (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher($exchange)))->install(); + $runner = self::runtime()->getRunner($kernel); + $globals = [$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES]; + + $runner->run(); + + return [$kernel->requests[0], $globals]; + } + + private function serveOne(FakeExchange $exchange): Request + { + $kernel = new TestKernel(); + $this->serve($kernel, $exchange); + + return $kernel->requests[0]; + } + + private function serve(TestKernel $kernel, FakeExchange ...$exchanges): void + { + (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install(); + + self::runtime()->getRunner($kernel)->run(); + } } diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php new file mode 100644 index 0000000..e959827 --- /dev/null +++ b/tests/Feature/DispatcherResponseTest.php @@ -0,0 +1,311 @@ + ['one', 'two']]); + $response->headers->setCookie(new Cookie('a', 'one')); + $response->headers->setCookie(new Cookie('b', 'two')); + + $this->serve(static fn(): Response => $response, $exchange); + + Assert::same($exchange->status, 201); + Assert::same($exchange->headers['X-Repeat'], ['one', 'two']); + Assert::count($exchange->headers['Set-Cookie'], 2); + Assert::same($exchange->chunks, ['body']); + Assert::true($exchange->isFinalized()); + } + + public function headRequestKeepsThePreparedContentLengthAndSendsNoBody(): void + { + $exchange = FakeExchange::for('/', 'HEAD'); + + $this->serve(static fn(): Response => new Response('must-not-send', headers: ['Content-Length' => '13']), $exchange); + + Assert::same($exchange->headers['Content-Length'], ['13']); + Assert::same($exchange->getBody(), ''); + Assert::true($exchange->isFinalized()); + } + + public function lowercaseHeadIsNotTreatedAsHead(): void + { + $exchange = FakeExchange::for('/', 'head'); + + $this->serve(static fn(): Response => new Response('body'), $exchange); + + Assert::same($exchange->getBody(), 'body'); + Assert::true($exchange->isFinalized()); + } + + public function emptyStatusesSendNoBody(): void + { + foreach ([204, 304] as $status) { + $exchange = FakeExchange::for('/'); + + $this->serve(static fn(): Response => new Response('must-not-send', $status), $exchange); + + Assert::same($exchange->status, $status); + Assert::same($exchange->getBody(), ''); + Assert::null($exchange->header('content-length')); + Assert::true($exchange->isFinalized()); + } + } + + public function streamedResponseIsForwardedAsItIsFlushedAndLeavesTheOuterBufferAlone(): void + { + $exchange = FakeExchange::for('/'); + $response = new StreamedResponse(static function (): void { + echo 'one'; + \ob_flush(); + echo 'two'; + }); + $level = \ob_get_level(); + + \ob_start(); + try { + $this->serve(static fn(): Response => $response, $exchange); + Assert::same(\ob_get_level(), $level + 1); + Assert::same(\ob_get_contents(), ''); + } finally { + \ob_end_clean(); + } + + Assert::same(\array_values(\array_filter($exchange->chunks, static fn(string $chunk): bool => $chunk !== '')), ['one', 'two']); + Assert::true($exchange->isFinalized()); + } + + public function streamClosedByTheHostLeaksNothingAndTheLoopGoesOn(): void + { + $gone = new ScriptedExchange(FakeExchange::for('/gone')); + $gone->bodyFailure = new WorkDiscardedException('gone'); + $next = FakeExchange::for('/next'); + $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function (): void { + echo 'must-not-leak'; + \ob_flush(); + echo 'also-hidden'; + })); + + \ob_start(); + try { + $this->serveWith($kernel, $gone, $next); + Assert::same(\ob_get_contents(), ''); + } finally { + \ob_end_clean(); + } + + Assert::same($kernel->events, ['handle:/gone', 'terminate:/gone', 'handle:/next', 'terminate:/next']); + Assert::true($next->isFinalized()); + } + + public function outputBufferTheResponseCannotRemoveFailsWithoutHanging(): void + { + $script = \tempnam(\sys_get_temp_dir(), 'rapira-buffer-test-'); + \file_put_contents($script, <<<'PHP' + install(); + $kernel = new TestKernel(static fn() => new StreamedResponse(static function (): void { + ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE); + echo 'trapped'; + })); + try { + (new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']))->getRunner($kernel)->run(); + } catch (LogicException $exception) { + fwrite(STDERR, $exception->getMessage()); + } + PHP); + + try { + [$status, $output] = self::runPhpScript($script); + } finally { + @\unlink($script); + } + + Assert::same($status, 0); + Assert::string($output)->contains('A nested output buffer cannot be removed safely.'); + } + + public function headRequestForAFileSendsItsLengthAndStillDeletesIt(): void + { + $path = self::file('0123456789'); + $exchange = FakeExchange::for('/file', 'HEAD'); + + $this->serve(static fn(): Response => (new BinaryFileResponse( + $path, + headers: ['Content-Type' => 'application/octet-stream'], + ))->deleteFileAfterSend(), $exchange); + + Assert::same($exchange->status, 200); + Assert::same($exchange->headers['Content-Length'], ['10']); + Assert::same(self::deliveredBody($exchange), ''); + Assert::true($exchange->isFinalized()); + Assert::false(\is_file($path)); + } + + public function emptyFileResponseStillDeletesTheFile(): void + { + foreach ([204, 304] as $status) { + $path = self::file('cleanup'); + $exchange = FakeExchange::for('/file'); + + $this->serve(static fn(): Response => (new BinaryFileResponse( + $path, + $status, + ['Content-Type' => 'application/octet-stream'], + ))->deleteFileAfterSend(), $exchange); + + Assert::same($exchange->status, $status); + Assert::same(self::deliveredBody($exchange), ''); + Assert::true($exchange->isFinalized()); + Assert::false(\is_file($path)); + } + } + + public function fileRangesAreServed(): void + { + $path = self::file('0123456789'); + $respond = static fn(): Response => (new BinaryFileResponse( + $path, + headers: ['Content-Type' => 'application/octet-stream'], + ))->setChunkSize(2); + $range = FakeExchange::for('/file', headers: ['range' => ['bytes=2-5']]); + $unsatisfiable = FakeExchange::for('/file', headers: ['range' => ['bytes=999-1000']]); + + try { + $this->serve($respond, $range, $unsatisfiable); + } finally { + @\unlink($path); + } + + Assert::same($range->status, 206); + Assert::same($range->headers['Content-Range'], ['bytes 2-5/10']); + Assert::same(self::deliveredBody($range), '2345'); + Assert::true($range->isFinalized()); + Assert::same($unsatisfiable->status, 416); + Assert::null($unsatisfiable->header('content-length')); + Assert::same(self::deliveredBody($unsatisfiable), ''); + Assert::true($unsatisfiable->isFinalized()); + } + + /** + * The body as the client receives it, whether written or handed to the host as a file. + */ + private static function deliveredBody(FakeExchange $exchange): string + { + $body = $exchange->getBody(); + foreach ($exchange->sentFiles as $file) { + $body .= (string) \file_get_contents($file['path'], offset: $file['offset'], length: $file['length']); + } + + return $body; + } + + private static function file(string $content): string + { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); + \file_put_contents($path, $content); + + return $path; + } + + /** + * @return array{int, string} The exit code and the combined output. + */ + private static function runPhpScript(string $script): array + { + $process = \proc_open( + [\PHP_BINARY, '-dxdebug.mode=off', $script], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + ); + if (!\is_resource($process)) { + throw new \RuntimeException('Unable to start the PHP subprocess.'); + } + + \fclose($pipes[0]); + \stream_set_blocking($pipes[1], false); + \stream_set_blocking($pipes[2], false); + $output = ''; + $exitCode = null; + $deadline = \microtime(true) + 10; + try { + do { + $output .= \stream_get_contents($pipes[1]); + $output .= \stream_get_contents($pipes[2]); + $status = \proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + break; + } + if (\microtime(true) >= $deadline) { + throw new \RuntimeException('PHP subprocess timed out.'); + } + \usleep(10_000); + } while (true); + + $output .= \stream_get_contents($pipes[1]); + $output .= \stream_get_contents($pipes[2]); + } finally { + if (\proc_get_status($process)['running']) { + \proc_terminate($process); + } + \fclose($pipes[1]); + \fclose($pipes[2]); + $closeStatus = \proc_close($process); + } + + return [$exitCode === -1 ? $closeStatus : $exitCode, $output]; + } + + /** + * @param \Closure(): Response $respond + */ + private function serve(\Closure $respond, FakeExchange ...$exchanges): void + { + $this->serveWith(new TestKernel($respond), ...$exchanges); + } + + private function serveWith(TestKernel $kernel, Exchange ...$exchanges): void + { + (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install(); + + Assert::same(self::runtime()->getRunner($kernel)->run(), 0); + } +} diff --git a/tests/Feature/RuntimeTest.php b/tests/Feature/RuntimeTest.php new file mode 100644 index 0000000..6cd7763 --- /dev/null +++ b/tests/Feature/RuntimeTest.php @@ -0,0 +1,98 @@ +queue('GET', '/')->install(); + $kernel = new TestKernel(); + + $runner = self::runtime()->getRunner($kernel); + Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); + $runner->run(); + + Assert::same($runtime->outputs, ['/']); + Assert::same($kernel->requests[0]->server->get('APP_RUNTIME_MODE'), 'web=1&worker=1'); + } + + public function dispatcherModeMarksTheProcessAsAWorkerForTheApplication(): void + { + unset($_SERVER['APP_RUNTIME_MODE']); + $exchange = FakeExchange::for('/'); + (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher($exchange)))->install(); + $kernel = new TestKernel(); + + $runner = self::runtime()->getRunner($kernel); + Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); + $runner->run(); + + Assert::same($exchange->getBody(), '/'); + Assert::same($kernel->requests[0]->server->get('APP_RUNTIME_MODE'), 'web=1&worker=1'); + } + + public function classicModeServesOneRequestFromTheSuperglobalsThroughSymfony(): void + { + unset($_SERVER['APP_RUNTIME_MODE']); + (new FakeRuntime(Mode::Classic))->install(); + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/classic', + 'HTTP_HOST' => 'localhost', + 'SCRIPT_NAME' => '/index.php', + ] + $_SERVER; + $kernel = new TestKernel(); + $runtime = self::runtime(); + Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); + + $output = ''; + // Symfony's runner closes every output buffer it may remove, so the output is taken as it is flushed. + \ob_start(static function (string $chunk) use (&$output): string { + $output .= $chunk; + + return ''; + }); + $level = \ob_get_level(); + try { + $result = $runtime->getRunner($kernel)->run(); + } finally { + \ob_get_level() === $level and \ob_end_flush(); + } + + Assert::same($result, 0); + Assert::same($output, '/classic'); + Assert::same($kernel->events, ['handle:/classic', 'terminate:/classic']); + } + + /** + * Pins the current delegation to Symfony; whether a resident mode should instead keep such an + * application alive is still open. + */ + public function nonHttpApplicationRunsOnceInResidentModes(): void + { + foreach ([Mode::Worker, Mode::Dispatcher] as $mode) { + (new FakeRuntime($mode, new FakeHttpDispatcher()))->install(); + + Assert::same(self::runtime()->getRunner(static fn(): int => 17)->run(), 17); + } + } +} diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php new file mode 100644 index 0000000..db03d9f --- /dev/null +++ b/tests/Feature/WorkerModeTest.php @@ -0,0 +1,150 @@ + 'first', 'APP_ENV' => 'request-env'], + cookies: ['session' => 'first'], + ), + WorkerRequest::create( + 'POST', + '/second?q=two', + ['HTTP_X_REQUEST' => 'second'], + cookies: ['session' => 'second'], + post: ['body' => 'second'], + ), + ]); + $kernel = new TestKernel(static fn(Request $request): Response => new Response('response-' . $request->query->get('q'))); + + $result = $this->run($runtime, $kernel); + + Assert::same($result, 0); + Assert::same($runtime->outputs, ['response-one', 'response-two']); + Assert::same(\array_map(self::describe(...), $kernel->requests), [ + [ + 'method' => 'GET', + 'path' => '/first', + 'query' => 'one', + 'body' => null, + 'cookie' => 'first', + 'header' => 'first', + 'app_env' => 'request-env', + 'secret' => 'boot-secret', + ], + [ + 'method' => 'POST', + 'path' => '/second', + 'query' => 'two', + 'body' => 'second', + 'cookie' => 'second', + 'header' => 'second', + 'app_env' => 'test', + 'secret' => 'boot-secret', + ], + ]); + Assert::notSame($kernel->requests[0], $kernel->requests[1]); + } + + public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/one') + ->queue('GET', '/two') + ->queue('GET', '/three'); + $kernel = new TestKernel(terminate: static function () use ($runtime, &$kernel): void { + $kernel->events[] = \sprintf('sent:%d served:%d', \count($runtime->outputs), $runtime->servedRequests); + }); + + $this->run($runtime, $kernel); + + Assert::same(\array_slice($kernel->events, 0, 6), [ + 'handle:/one', 'terminate:/one', 'sent:1 served:1', + 'handle:/two', 'terminate:/two', 'sent:2 served:2', + ]); + Assert::same($runtime->outputs, ['/one', '/two', '/three']); + } + + public function terminateFailurePropagatesBeforeTheNextRequest(): never + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/failure') + ->queue('GET', '/must-not-run'); + $kernel = new TestKernel(terminate: static fn() => throw new \RuntimeException('terminate failed')); + + Expect::exception(\RuntimeException::class)->withMessage('terminate failed'); + try { + $this->run($runtime, $kernel); + } finally { + Assert::same($kernel->events, ['handle:/failure', 'terminate:/failure']); + Assert::same($runtime->servedRequests, 1); + } + } + + public function kernelFailurePropagatesWithoutTerminatingOrContinuing(): never + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/failure') + ->queue('GET', '/must-not-run'); + $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); + + Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); + try { + $this->run($runtime, $kernel); + } finally { + Assert::same($kernel->events, ['handle:/failure']); + Assert::same($runtime->servedRequests, 1); + } + } + + /** + * @return array + */ + private static function describe(Request $request): array + { + return [ + 'method' => $request->getMethod(), + 'path' => $request->getPathInfo(), + 'query' => $request->query->get('q'), + 'body' => $request->request->get('body'), + 'cookie' => $request->cookies->get('session'), + 'header' => $request->headers->get('x-request'), + 'app_env' => $request->server->get('APP_ENV'), + 'secret' => $request->server->get('APP_SECRET'), + ]; + } + + private function run(FakeRuntime $runtime, TestKernel $kernel): int + { + $runtime->install(); + + return self::runtime()->getRunner($kernel)->run(); + } +} diff --git a/tests/Support/FakeRuntimeLifecycle.php b/tests/Support/FakeRuntimeLifecycle.php new file mode 100644 index 0000000..9efeb6d --- /dev/null +++ b/tests/Support/FakeRuntimeLifecycle.php @@ -0,0 +1,43 @@ +> */ + private array $processGlobals = []; + + #[BeforeTest] + public function rememberProcessGlobals(): void + { + $this->processGlobals = [$_SERVER, $_ENV, $_GET, $_POST, $_COOKIE, $_FILES, $_REQUEST]; + } + + #[AfterTest] + public function resetRuntime(): void + { + FakeRuntime::reset(); + [$_SERVER, $_ENV, $_GET, $_POST, $_COOKIE, $_FILES, $_REQUEST] = $this->processGlobals; + } + + /** + * The Runtime as `autoload_runtime.php` would build it, minus the process-wide error handler and the + * debug-mode `umask(0)`, which would outlive the test. + */ + private static function runtime(): Runtime + { + return new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']); + } +} diff --git a/tests/Support/ForeignDispatcher.php b/tests/Support/ForeignDispatcher.php new file mode 100644 index 0000000..a19801e --- /dev/null +++ b/tests/Support/ForeignDispatcher.php @@ -0,0 +1,39 @@ +superglobals = [ - 'server' => $_SERVER, - 'env' => $_ENV, - 'get' => $_GET, - 'post' => $_POST, - 'cookie' => $_COOKIE, - 'files' => $_FILES, - 'request' => $_REQUEST, - ]; - $this->ignoreUserAbort = \ignore_user_abort(); - $this->outputBufferLevel = \ob_get_level(); - } - - #[AfterTest] - public function restoreProcessState(): void - { - try { - $this->restoreOutputBufferLevel($this->outputBufferLevel); - } finally { - $_SERVER = $this->superglobals['server']; - $_ENV = $this->superglobals['env']; - $_GET = $this->superglobals['get']; - $_POST = $this->superglobals['post']; - $_COOKIE = $this->superglobals['cookie']; - $_FILES = $this->superglobals['files']; - $_REQUEST = $this->superglobals['request']; - \ignore_user_abort((bool) $this->ignoreUserAbort); - } - } - - private function restoreOutputBufferLevel(int $level): void - { - while (\ob_get_level() > $level) { - if (!@\ob_end_clean()) { - throw new \RuntimeException('Unable to restore the output buffer level.'); - } - } - } -} diff --git a/tests/Support/NullKernel.php b/tests/Support/NullKernel.php deleted file mode 100644 index 0f85154..0000000 --- a/tests/Support/NullKernel.php +++ /dev/null @@ -1,17 +0,0 @@ -runtimeMode; - } - - protected function getHttpDispatcher(): HttpDispatcher - { - return $this->httpDispatcher ?? parent::getHttpDispatcher(); - } -} diff --git a/tests/Support/ScriptedExchange.php b/tests/Support/ScriptedExchange.php new file mode 100644 index 0000000..e309b29 --- /dev/null +++ b/tests/Support/ScriptedExchange.php @@ -0,0 +1,85 @@ + Statuses of every accepted {@see writeHead()}, interim ones included. */ + public array $heads = []; + + /** Thrown by every {@see writeBody()} once set. */ + public ?\Throwable $bodyFailure = null; + + public function __construct( + public readonly FakeExchange $exchange, + ) {} + + #[\Override] + public function getRequest(): Request + { + return $this->exchange->getRequest(); + } + + #[\Override] + public function writeHead(int $status, array $headers = []): void + { + // The host refuses what HTTP/1.1 field syntax cannot carry; FakeExchange takes anything. + foreach ($headers as $name => $values) { + foreach ($values as $value) { + \preg_match('/[\r\n\0]/', $value) === 1 + and throw new \ValueError(\sprintf('Header "%s" has a value not representable on the wire.', $name)); + } + } + + $this->exchange->writeHead($status, $headers); + $this->heads[] = $status; + } + + #[\Override] + public function writeBody(string $content, bool $eos = true): void + { + $this->bodyFailure === null or throw $this->bodyFailure; + + $this->exchange->writeBody($content, $eos); + } + + #[\Override] + public function sendFile(string $path, int $offset = 0, ?int $length = null, bool $eos = true): void + { + $this->exchange->sendFile($path, $offset, $length, $eos); + } + + #[\Override] + public function writeTrailers(array $trailers): void + { + $this->exchange->writeTrailers($trailers); + } + + #[\Override] + public function flush(): void + { + $this->exchange->flush(); + } + + #[\Override] + public function isFinalized(): bool + { + return $this->exchange->isFinalized(); + } + + #[\Override] + public function isCancelled(): bool + { + return $this->exchange->isCancelled(); + } +} diff --git a/tests/Support/StubExchange.php b/tests/Support/StubExchange.php deleted file mode 100644 index 708f0a9..0000000 --- a/tests/Support/StubExchange.php +++ /dev/null @@ -1,75 +0,0 @@ ->}> */ - public array $heads = []; - - /** @var list */ - public array $bodies = []; - - public bool $cancelled = false; - public bool $finalized = false; - public int $cancellationChecks = 0; - public ?\Throwable $headFailure = null; - public ?\Throwable $bodyFailure = null; - - public function __construct(private readonly Request $request) {} - - public function getRequest(): Request - { - return $this->request; - } - - public function isFinalized(): bool - { - return $this->finalized; - } - - public function isCancelled(): bool - { - ++$this->cancellationChecks; - - return $this->cancelled; - } - - public function writeHead(int $status, array $headers = []): void - { - if ($this->headFailure !== null) { - throw $this->headFailure; - } - - $this->heads[] = ['status' => $status, 'headers' => $headers]; - } - - public function writeBody(string $content, bool $eos = true): void - { - if ($this->bodyFailure !== null) { - throw $this->bodyFailure; - } - - $this->bodies[] = ['content' => $content, 'eos' => $eos]; - $this->finalized = $eos; - } - - public function sendFile(string $path, int $offset = 0, ?int $length = null, bool $eos = true): void - { - throw new \BadMethodCallException(); - } - - public function writeTrailers(array $trailers): void - { - throw new \BadMethodCallException(); - } - - public function flush(): void {} - - public function __destruct() {} -} diff --git a/tests/Support/StubHttpDispatcher.php b/tests/Support/StubHttpDispatcher.php deleted file mode 100644 index 7582286..0000000 --- a/tests/Support/StubHttpDispatcher.php +++ /dev/null @@ -1,33 +0,0 @@ - Every request handled, in order. */ + public array $requests = []; + + /** @var list `handle:` and `terminate:` in call order; tests may append their own. */ + public array $events = []; + + /** @var \Closure(Request): Response */ + private readonly \Closure $respond; + + /** + * @param null|\Closure(Request): Response $respond Null answers `200` with the path as the body. + * @param null|\Closure(Request, Response): void $terminate Runs after the event is recorded. + */ + public function __construct( + ?\Closure $respond = null, + private readonly ?\Closure $terminate = null, + ) { + $this->respond = $respond ?? static fn(Request $request): Response => new Response($request->getPathInfo()); + } + + #[\Override] + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $this->requests[] = $request; + $this->events[] = 'handle:' . $request->getPathInfo(); + + return ($this->respond)($request); + } + + #[\Override] + public function terminate(Request $request, Response $response): void + { + $this->events[] = 'terminate:' . $request->getPathInfo(); + $this->terminate === null or ($this->terminate)($request, $response); + } +} diff --git a/tests/Unit/DispatcherRunnerTest.php b/tests/Unit/DispatcherRunnerTest.php deleted file mode 100644 index 607d228..0000000 --- a/tests/Unit/DispatcherRunnerTest.php +++ /dev/null @@ -1,250 +0,0 @@ -exchange('/one'), - $this->exchange('/two'), - ], $events); - $kernel = new LifecycleKernel($events); - - $result = $this->runner($kernel, $dispatcher)->run(); - - Assert::same($result, 0); - Assert::same($kernel->paths, ['/one', '/two']); - Assert::false($kernel->requests[0] === $kernel->requests[1]); - Assert::same($events, [ - 'receive', 'handle:/one', 'terminate:/one', - 'receive', 'handle:/two', 'terminate:/two', - 'receive', 'closed', - ]); - } - - public function cancellationContinuesToNextExchangeWithoutTerminatingCancelledRequest(): void - { - $events = []; - $cancelled = $this->exchange('/cancelled'); - $cancelled->cancelled = true; - $healthy = $this->exchange('/healthy'); - $dispatcher = new QueueHttpDispatcher([$cancelled, $healthy], $events); - $kernel = new LifecycleKernel($events); - - Assert::same($this->runner($kernel, $dispatcher)->run(), 0); - Assert::same($kernel->paths, ['/healthy']); - Assert::same($kernel->terminated, ['/healthy']); - Assert::same($cancelled->heads, []); - Assert::true($healthy->finalized); - } - - public function discardedWriteContinuesButUnexpectedKernelErrorsPropagate(): void - { - $events = []; - $discarded = new DiscardingExchange($this->request('/discarded')); - $healthy = $this->exchange('/healthy'); - $dispatcher = new QueueHttpDispatcher([$discarded, $healthy], $events); - $kernel = new LifecycleKernel($events); - Assert::same($this->runner($kernel, $dispatcher)->run(), 0); - Assert::true($healthy->finalized); - Assert::same($kernel->terminated, ['/discarded', '/healthy']); - - $events = []; - $dispatcher = new QueueHttpDispatcher([$this->exchange('/terminate-fail')], $events); - $kernel = new DiscardingTerminateKernel($events); - Expect::exception(WorkDiscardedException::class)->withMessage('application terminate discard'); - try { - $this->runner($kernel, $dispatcher)->run(); - } finally { - Assert::same($dispatcher->receiveCalls, 1); - } - - $events = []; - $dispatcher = new QueueHttpDispatcher([$this->exchange('/fail'), $this->exchange('/must-not-run')], $events); - $kernel = new ThrowingDispatcherKernel($events); - Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); - try { - $this->runner($kernel, $dispatcher)->run(); - } finally { - Assert::same($dispatcher->receiveCalls, 1); - } - } - - private function runner(HttpKernelInterface $kernel, HttpDispatcher $dispatcher): DispatcherRunner - { - return new DispatcherRunner( - $kernel, - $dispatcher, - new DispatcherRequestFactory([]), - new ExchangeResponseEmitter(), - ); - } - - private function exchange(string $path): StubExchange - { - return new StubExchange($this->request($path)); - } - - private function request(string $path): RapiraRequest - { - return new RapiraRequest( - 'GET', - 'http://localhost' . $path, - $path, - 'localhost', - 'HTTP/1.1', - [], - '', - new InetAddress('127.0.0.1', 40000), - new InetAddress('127.0.0.1', 8080), - null, - 0.0, - ); - } -} - -final class QueueHttpDispatcher implements HttpDispatcher -{ - public int $receiveCalls = 0; - - /** - * @param list $exchanges - * @param list $events - */ - public function __construct( - private array $exchanges, - private array &$events, - ) {} - - public function name(): string - { - return 'http'; - } - - public function tryReceive(): ?Exchange - { - return \array_shift($this->exchanges); - } - - public function receive(int $timeout = -1): Exchange - { - ++$this->receiveCalls; - $this->events[] = 'receive'; - $exchange = \array_shift($this->exchanges); - if ($exchange === null) { - $this->events[] = 'closed'; - throw new ClosedException(); - } - - return $exchange; - } - - public function getInfo(): HttpDispatcherInfo - { - throw new \BadMethodCallException(); - } -} - -final class LifecycleKernel implements HttpKernelInterface, TerminableInterface -{ - /** @var list */ - public array $paths = []; - - /** @var list */ - public array $requests = []; - - /** @var list */ - public array $terminated = []; - - /** - * @param list $events - */ - public function __construct(private array &$events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $path = $request->getPathInfo(); - $this->paths[] = $path; - $this->requests[] = $request; - $this->events[] = 'handle:' . $path; - - return new Response($path); - } - - public function terminate(Request $request, Response $response): void - { - $path = $request->getPathInfo(); - $this->terminated[] = $path; - $this->events[] = 'terminate:' . $path; - } -} - -final class ThrowingDispatcherKernel implements HttpKernelInterface -{ - /** - * @param list $events - */ - public function __construct(private array &$events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->events[] = 'handle:' . $request->getPathInfo(); - throw new \RuntimeException('kernel failed'); - } -} - -final class DiscardingTerminateKernel implements HttpKernelInterface, TerminableInterface -{ - /** - * @param list $events - */ - public function __construct(private array &$events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->events[] = 'handle:' . $request->getPathInfo(); - - return new Response('ok'); - } - - public function terminate(Request $request, Response $response): void - { - throw new WorkDiscardedException('application terminate discard'); - } -} - -final class DiscardingExchange extends StubExchange -{ - public function writeHead(int $status, array $headers = []): void - { - throw new WorkDiscardedException(); - } -} diff --git a/tests/Unit/ExchangeResponseEmitterTest.php b/tests/Unit/ExchangeResponseEmitterTest.php deleted file mode 100644 index fbdf426..0000000 --- a/tests/Unit/ExchangeResponseEmitterTest.php +++ /dev/null @@ -1,335 +0,0 @@ -exchange(); - $response = new Response('body', 201, ['X-Repeat' => ['one', 'two']]); - $response->headers->setCookie(new Cookie('a', 'one')); - $response->headers->setCookie(new Cookie('b', 'two')); - - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); - - Assert::same($exchange->heads[0]['status'], 201); - Assert::same($exchange->heads[0]['headers']['X-Repeat'], ['one', 'two']); - Assert::count($exchange->heads[0]['headers']['Set-Cookie'], 2); - Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); - Assert::same($exchange->cancellationChecks, 0); - } - - public function ordinaryHeadRetainsPreparedContentLengthAndEmitsNoBody(): void - { - $exchange = $this->exchange('HEAD'); - $request = Request::create('/', 'HEAD'); - $request->attributes->set('rapira.request_method', 'HEAD'); - $response = new Response('must-not-send', headers: ['Content-Length' => '13']); - - (new ExchangeResponseEmitter())->emit($exchange, $request, $response); - - Assert::same($exchange->heads[0]['headers']['Content-Length'], ['13']); - Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); - } - - public function lowercaseHeadIsNotGivenStandardHeadTransportSemantics(): void - { - $exchange = $this->exchange('head'); - $request = Request::create('/', 'head'); - $request->attributes->set('rapira.request_method', 'head'); - - (new ExchangeResponseEmitter())->emit($exchange, $request, new Response('body')); - - Assert::same($exchange->bodies, [['content' => 'body', 'eos' => true]]); - } - - public function emptyResponsesEmitNoBody(): void - { - foreach ([204, 304] as $status) { - $exchange = $this->exchange(); - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('must-not-run', $status)); - Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); - Assert::false(isset($exchange->heads[0]['headers']['Content-Length'])); - } - } - - public function rejectsTerminalInterimResponsesBeforeWriting(): void - { - $exchange = $this->exchange(); - Expect::exception(\LogicException::class) - ->withMessage('A terminal Symfony response cannot use an interim HTTP status.'); - try { - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), new Response('', 103)); - } finally { - Assert::same($exchange->heads, []); - Assert::same($exchange->bodies, []); - } - } - - public function streamsProgressiveChunksAndRestoresOuterBuffer(): void - { - $exchange = $this->exchange(); - $response = new StreamedResponse(static function (): void { - echo 'one'; - \ob_flush(); - echo 'two'; - }); - $level = \ob_get_level(); - \ob_start(); - try { - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); - Assert::same(\ob_get_level(), $level + 1); - Assert::same(\ob_get_contents(), ''); - } finally { - \ob_end_clean(); - } - - Assert::same($exchange->bodies, [ - ['content' => 'one', 'eos' => false], - ['content' => 'two', 'eos' => false], - ['content' => '', 'eos' => true], - ]); - } - - public function capturesStreamingWriteFailureWithoutLeakingToOuterBuffer(): void - { - $exchange = $this->exchange(); - $exchange->bodyFailure = new WorkDiscardedException('gone'); - $response = new StreamedResponse(static function (): void { - echo 'must-not-leak'; - \ob_flush(); - echo 'also-hidden'; - }); - - \ob_start(); - Expect::exception(ResponseDiscardedException::class); - try { - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); - } finally { - Assert::same(\ob_get_contents(), ''); - \ob_end_clean(); - } - } - - public function nonRemovableNestedBufferFailsWithoutHanging(): void - { - $script = \tempnam(\sys_get_temp_dir(), 'rapira-buffer-test-'); - \file_put_contents($script, <<<'PHP' -emit( - $exchange, - Symfony\Component\HttpFoundation\Request::create('/'), - $response, - ); -} catch (LogicException $exception) { - fwrite(STDERR, $exception->getMessage()); -} -PHP); - - try { - [$status, $output] = $this->runPhpScript($script); - } finally { - @\unlink($script); - } - - Assert::same($status, 0); - Assert::true(\str_contains($output, 'A nested output buffer cannot be removed safely.')); - } - - public function eventStreamResponseIsRejectedWhenAvailable(): void - { - if (!\class_exists(\Symfony\Component\HttpFoundation\EventStreamResponse::class)) { - Assert::true(true); - - return; - } - - $exchange = $this->exchange(); - $response = new \Symfony\Component\HttpFoundation\EventStreamResponse(); - Expect::exception(\LogicException::class) - ->withMessage('Symfony EventStreamResponse is not supported in Rapira Dispatcher mode.'); - try { - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/'), $response); - } finally { - Assert::same($exchange->heads, []); - } - } - - public function binaryHeadRetainsFileLengthAndDeleteAfterSendCleansFile(): void - { - $path = $this->file('0123456789'); - $exchange = $this->exchange('HEAD'); - $request = Request::create('/file', 'HEAD'); - $request->attributes->set('rapira.request_method', 'HEAD'); - $response = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); - $response->deleteFileAfterSend(); - - (new ExchangeResponseEmitter())->emit($exchange, $request, $response); - - Assert::same($exchange->heads[0]['headers']['Content-Length'], ['10']); - Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); - Assert::false(\is_file($path)); - } - - public function emptyBinaryResponseRunsDeleteAfterSendCleanup(): void - { - foreach ([204, 304] as $status) { - $path = $this->file('cleanup'); - $response = new BinaryFileResponse( - $path, - $status, - ['Content-Type' => 'application/octet-stream'], - ); - $response->deleteFileAfterSend(); - $exchange = $this->exchange(); - - (new ExchangeResponseEmitter())->emit($exchange, Request::create('/file'), $response); - - Assert::same($exchange->bodies, [['content' => '', 'eos' => true]]); - Assert::false(\is_file($path)); - } - } - - public function emitsBinaryFilesAndRangesThroughSymfonySendContent(): void - { - $path = $this->file('0123456789'); - $exchange = $this->exchange(); - $request = Request::create('/file', 'GET', server: ['HTTP_RANGE' => 'bytes=2-5']); - $response = new BinaryFileResponse($path, headers: ['Content-Type' => 'application/octet-stream']); - $response->setChunkSize(2); - (new ExchangeResponseEmitter())->emit($exchange, $request, $response); - - Assert::same($exchange->heads[0]['status'], 206); - Assert::same($exchange->heads[0]['headers']['Content-Range'], ['bytes 2-5/10']); - Assert::same(\implode('', \array_column($exchange->bodies, 'content')), '2345'); - Assert::true($exchange->finalized); - - $unsatisfiable = $this->exchange(); - $unsatisfiableRequest = Request::create('/file', 'GET', server: ['HTTP_RANGE' => 'bytes=999-1000']); - $unsatisfiableResponse = new BinaryFileResponse( - $path, - headers: ['Content-Type' => 'application/octet-stream'], - ); - (new ExchangeResponseEmitter())->emit($unsatisfiable, $unsatisfiableRequest, $unsatisfiableResponse); - Assert::same($unsatisfiable->heads[0]['status'], 416); - Assert::false(isset($unsatisfiable->heads[0]['headers']['Content-Length'])); - Assert::same($unsatisfiable->bodies, [['content' => '', 'eos' => true]]); - - @\unlink($path); - } - - /** - * @return array{int, string} - */ - private function runPhpScript(string $script): array - { - $process = \proc_open( - [\PHP_BINARY, $script], - [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], - $pipes, - ); - if (!\is_resource($process)) { - throw new \RuntimeException('Unable to start the PHP subprocess.'); - } - - \fclose($pipes[0]); - \stream_set_blocking($pipes[1], false); - \stream_set_blocking($pipes[2], false); - $output = ''; - $exitCode = null; - $deadline = \microtime(true) + 5; - try { - do { - $output .= \stream_get_contents($pipes[1]); - $output .= \stream_get_contents($pipes[2]); - $status = \proc_get_status($process); - if (!$status['running']) { - $exitCode = $status['exitcode']; - break; - } - if (\microtime(true) >= $deadline) { - throw new \RuntimeException('PHP subprocess timed out.'); - } - \usleep(10_000); - } while (true); - - $output .= \stream_get_contents($pipes[1]); - $output .= \stream_get_contents($pipes[2]); - } finally { - if (\proc_get_status($process)['running']) { - \proc_terminate($process); - } - \fclose($pipes[1]); - \fclose($pipes[2]); - $closeStatus = \proc_close($process); - } - - return [$exitCode === -1 ? $closeStatus : $exitCode, $output]; - } - - private function file(string $content): string - { - $path = \tempnam(\sys_get_temp_dir(), 'rapira-binary-'); - \file_put_contents($path, $content); - - return $path; - } - - private function exchange(string $method = 'GET'): StubExchange - { - return new StubExchange(new RapiraRequest( - $method, - 'http://localhost/', - '/', - 'localhost', - 'HTTP/1.1', - [], - '', - new InetAddress('127.0.0.1', 40000), - new InetAddress('127.0.0.1', 8080), - null, - 0.0, - )); - } -} diff --git a/tests/Unit/RuntimeTest.php b/tests/Unit/RuntimeTest.php deleted file mode 100644 index cfc42a8..0000000 --- a/tests/Unit/RuntimeTest.php +++ /dev/null @@ -1,64 +0,0 @@ - false, 'error_handler' => false]); - - Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); - Assert::true($runtime->getRunner(new NullKernel()) instanceof WorkerRunner); - } - - public function dispatcherModeSelectsDispatcherRunnerAndSetsRuntimeModeBeforeSymfonyInitialization(): void - { - unset($_SERVER['APP_RUNTIME_MODE']); - - $runtime = new RuntimeForMode( - Mode::Dispatcher, - ['debug' => false, 'error_handler' => false], - new StubHttpDispatcher(), - ); - - Assert::same($_SERVER['APP_RUNTIME_MODE'], 'web=1&worker=1'); - Assert::true($runtime->getRunner(new NullKernel()) instanceof DispatcherRunner); - } - - public function classicModeDelegatesToSymfonyRuntime(): void - { - unset($_SERVER['APP_RUNTIME_MODE']); - - $runtime = new RuntimeForMode(Mode::Classic, ['debug' => false, 'error_handler' => false]); - - Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); - Assert::true($runtime->getRunner(new NullKernel()) instanceof HttpKernelRunner); - } - - public function nonHttpApplicationsDelegateInResidentModes(): void - { - foreach ([Mode::Worker, Mode::Dispatcher] as $mode) { - $runtime = new RuntimeForMode($mode, ['debug' => false, 'error_handler' => false]); - Assert::same($runtime->getRunner(static fn(): int => 17)->run(), 17); - } - } -} diff --git a/tests/Unit/WorkerRunnerTest.php b/tests/Unit/WorkerRunnerTest.php deleted file mode 100644 index 4a5e270..0000000 --- a/tests/Unit/WorkerRunnerTest.php +++ /dev/null @@ -1,318 +0,0 @@ - 'test', - 'APP_SECRET' => 'boot-secret', - 'HTTP_BOOT_ONLY' => 'must-not-leak', - ]; - $kernel = new RecordingKernel(); - $loop = new FakeRequestLoop([ - [ - 'server' => [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => '/first?q=one', - 'QUERY_STRING' => 'q=one', - 'HTTP_HOST' => 'localhost', - 'HTTP_X_REQUEST' => 'first', - 'APP_ENV' => 'request-env', - ], - 'get' => ['q' => 'one'], - 'post' => [], - 'cookie' => ['session' => 'first'], - ], - [ - 'server' => [ - 'REQUEST_METHOD' => 'POST', - 'REQUEST_URI' => '/second?q=two', - 'QUERY_STRING' => 'q=two', - 'HTTP_HOST' => 'localhost', - 'HTTP_X_REQUEST' => 'second', - ], - 'get' => ['q' => 'two'], - 'post' => ['body' => 'second'], - 'cookie' => ['session' => 'second'], - ], - ]); - - \ob_start(); - try { - $result = (new WorkerRunner($kernel, $loop))->run(); - $output = (string) \ob_get_contents(); - } finally { - \ob_end_clean(); - } - - Assert::same($result, 0); - Assert::same($output, 'response-1response-2'); - Assert::same($kernel->handled, [ - [ - 'method' => 'GET', - 'path' => '/first', - 'query' => 'one', - 'body' => null, - 'cookie' => 'first', - 'header' => 'first', - 'app_env' => 'request-env', - 'secret' => 'boot-secret', - 'boot_header' => null, - ], - [ - 'method' => 'POST', - 'path' => '/second', - 'query' => 'two', - 'body' => 'second', - 'cookie' => 'second', - 'header' => 'second', - 'app_env' => 'test', - 'secret' => 'boot-secret', - 'boot_header' => null, - ], - ]); - Assert::false($kernel->requests[0] === $kernel->requests[1]); - Assert::same($loop->handlers, 2); - } - - public function sendsThenTerminatesExactlyOncePerRequestAndDrains(): void - { - $_SERVER = ['APP_ENV' => 'test']; - $events = new Events(); - $kernel = new TerminableRecordingKernel($events); - $loop = new FakeRequestLoop([$this->requestGlobals('/lifecycle')], $events); - - \ob_start(); - try { - $result = (new WorkerRunner($kernel, $loop))->run(); - } finally { - \ob_end_clean(); - } - - Assert::same($result, 0); - Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate', 'drain']); - Assert::same($kernel->terminateCalls, 1); - } - - public function terminateExceptionsPropagateBeforeCollectionAndTheNextRequest(): void - { - $_SERVER = ['APP_ENV' => 'test']; - $events = new Events(); - $kernel = new TerminateThrowingKernel($events); - $loop = new FakeRequestLoop([ - $this->requestGlobals('/failure'), - $this->requestGlobals('/must-not-run'), - ], $events); - - Expect::exception(\RuntimeException::class)->withMessage('terminate failed'); - try { - (new WorkerRunner($kernel, $loop))->run(); - } finally { - Assert::same($events->values, ['loop:before', 'handle', 'send:true', 'loop:after', 'terminate']); - Assert::same($loop->handlers, 1); - } - } - - public function propagatesExceptionsWithoutTerminatingOrContinuing(): void - { - $_SERVER = ['APP_ENV' => 'test']; - $events = new Events(); - $kernel = new ThrowingKernel($events); - $loop = new FakeRequestLoop([ - $this->requestGlobals('/failure'), - $this->requestGlobals('/must-not-run'), - ], $events); - - Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); - try { - (new WorkerRunner($kernel, $loop))->run(); - } finally { - Assert::same($events->values, ['loop:before', 'handle']); - Assert::same($kernel->terminateCalls, 0); - Assert::same($loop->handlers, 1); - } - } - - /** - * @return array{server: array, get: array{}, post: array{}, cookie: array{}} - */ - private function requestGlobals(string $path): array - { - return [ - 'server' => [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => $path, - 'HTTP_HOST' => 'localhost', - ], - 'get' => [], - 'post' => [], - 'cookie' => [], - ]; - } -} - -final class FakeRequestLoop implements RequestLoop -{ - public int $handlers = 0; - - /** - * @param list, get: array, post: array, cookie: array}> $requests - */ - public function __construct( - private array $requests, - private readonly ?Events $events = null, - ) {} - - public function handle(callable $handler): bool - { - $request = \array_shift($this->requests); - if ($request === null) { - if ($this->events !== null) { - $this->events->values[] = 'drain'; - } - - return false; - } - - $_SERVER = $request['server']; - $_GET = $request['get']; - $_POST = $request['post']; - $_COOKIE = $request['cookie']; - $_FILES = []; - ++$this->handlers; - if ($this->events !== null) { - $this->events->values[] = 'loop:before'; - } - $handler(); - if ($this->events !== null) { - $this->events->values[] = 'loop:after'; - } - - return true; - } -} - -class RecordingKernel implements HttpKernelInterface -{ - /** @var list> */ - public array $handled = []; - - /** @var list */ - public array $requests = []; - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->requests[] = $request; - $this->handled[] = [ - 'method' => $request->getMethod(), - 'path' => $request->getPathInfo(), - 'query' => $request->query->get('q'), - 'body' => $request->request->get('body'), - 'cookie' => $request->cookies->get('session'), - 'header' => $request->headers->get('x-request'), - 'app_env' => $request->server->get('APP_ENV'), - 'secret' => $request->server->get('APP_SECRET'), - 'boot_header' => $request->headers->get('boot-only'), - ]; - - return new Response('response-' . \count($this->handled)); - } -} - -final class TerminableRecordingKernel implements HttpKernelInterface, TerminableInterface -{ - public int $terminateCalls = 0; - - public function __construct(private readonly Events $events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->events->values[] = 'handle'; - - return new EventResponse($this->events); - } - - public function terminate(Request $request, Response $response): void - { - ++$this->terminateCalls; - $this->events->values[] = 'terminate'; - } -} - -final class TerminateThrowingKernel implements HttpKernelInterface, TerminableInterface -{ - public function __construct(private readonly Events $events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->events->values[] = 'handle'; - - return new EventResponse($this->events); - } - - public function terminate(Request $request, Response $response): void - { - $this->events->values[] = 'terminate'; - - throw new \RuntimeException('terminate failed'); - } -} - -final class ThrowingKernel implements HttpKernelInterface, TerminableInterface -{ - public int $terminateCalls = 0; - - public function __construct(private readonly Events $events) {} - - public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response - { - $this->events->values[] = 'handle'; - - throw new \RuntimeException('kernel failed'); - } - - public function terminate(Request $request, Response $response): void - { - ++$this->terminateCalls; - } -} - -final class EventResponse extends Response -{ - public function __construct(private readonly Events $events) - { - parent::__construct(); - } - - public function send(bool $flush = true): static - { - $this->events->values[] = 'send:' . ($flush ? 'true' : 'false'); - - return $this; - } -} - -final class Events -{ - /** @var list */ - public array $values = []; -} From 6e30a49b1317f31607971160d980855f38098c70 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 23:20:48 +0400 Subject: [PATCH 17/42] test: pin the known dispatcher and worker bugs as skipped tests The review found dispatcher defects in cookie parsing, form parsing, HEAD responses for files and the handling of failures before the response head is written, and the Feature suite showed that WorkerRunner never terminates the request served by the last handle_request() call when that call already returns false. Each test states the intended behaviour and is skipped with a "known bug" reason, so the fixes in src come with ready regression tests while CI stays green. The doubles cannot see how much of a file a response reads, so a stream wrapper counts it, and FakeExchange accepts any header value, so ScriptedExchange rejects what the host would. Assisted-By: Claude Opus 5.5 --- composer.json | 1 + tests/Feature/DispatcherModeTest.php | 63 +++++++++++++ tests/Feature/DispatcherRequestTest.php | 60 +++++++++++++ tests/Feature/DispatcherResponseTest.php | 29 ++++++ tests/Feature/WorkerModeTest.php | 14 +++ tests/Support/ReadCountingStream.php | 107 +++++++++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 tests/Support/ReadCountingStream.php diff --git a/composer.json b/composer.json index 829ff83..37b0303 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "spiral/code-style": "^2.3.1", "testo/assert": "^0.1.17", "testo/lifecycle": "^0.1.9", + "testo/skip": "^0.1.1", "testo/test": "^0.1.8", "testo/testo": "^0.10.55", "vimeo/psalm": "^7" diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php index 25be7a3..24fb9b7 100644 --- a/tests/Feature/DispatcherModeTest.php +++ b/tests/Feature/DispatcherModeTest.php @@ -5,6 +5,9 @@ namespace Rapira\Symfony\Tests\Feature; use Rapira\Exception\WorkDiscardedException; +use Rapira\Http\Exchange; +use Rapira\Http\Multipart; +use Rapira\Http\UploadedFile; use Rapira\Mode; use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Sdk\Testing\Double\Http\FakeExchange; @@ -14,9 +17,11 @@ use Rapira\Symfony\Tests\Support\ScriptedExchange; use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\EventStreamResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Testo\Assert; use Testo\Expect; +use Testo\Skip; use Testo\Test; /** @@ -169,6 +174,64 @@ public function eventStreamResponseIsNeverStreamed(): void Assert::string($exchange->exchange->getBody())->notContains('event'); } + #[Skip('known bug: a request that fails to convert kills the loop instead of answering 500')] + public function requestConversionFailureIsAnsweredWithAnErrorAndTheLoopGoesOn(): void + { + $missing = \sys_get_temp_dir() . '/rapira-missing-' . \bin2hex(\random_bytes(8)); + $failing = FakeExchange::for('/upload', 'POST', body: new Multipart([], [ + new UploadedFile('file', 'named.txt', 'text/plain', [], $missing, 10), + ])); + + $this->assertAnsweredWith500AndTheNextServed($failing, $failing, new TestKernel()); + } + + #[Skip('known bug: an EventStreamResponse kills the loop instead of answering 500')] + public function eventStreamResponseIsAnsweredWithAnErrorAndTheLoopGoesOn(): void + { + $failing = FakeExchange::for('/events'); + $kernel = new TestKernel(static fn(Request $request): Response => $request->getPathInfo() === '/events' + ? new EventStreamResponse(static fn(): iterable => yield 'event') + : new Response($request->getPathInfo())); + + $this->assertAnsweredWith500AndTheNextServed($failing, $failing, $kernel); + } + + #[Skip('known bug: a terminal 1xx response kills the loop instead of answering 500')] + public function terminalInterimStatusIsAnsweredWithAnErrorAndTheLoopGoesOn(): void + { + $failing = FakeExchange::for('/early'); + $kernel = new TestKernel(static fn(Request $request): Response => $request->getPathInfo() === '/early' + ? new Response('', 103) + : new Response($request->getPathInfo())); + + $this->assertAnsweredWith500AndTheNextServed($failing, $failing, $kernel); + } + + #[Skip('known bug: a header value the host rejects kills the loop instead of answering 500')] + public function headerValueTheHostRejectsIsAnsweredWithAnErrorAndTheLoopGoesOn(): void + { + $failing = new ScriptedExchange(FakeExchange::for('/bad-header')); + $kernel = new TestKernel(static fn(Request $request): Response => $request->getPathInfo() === '/bad-header' + ? new Response('body', 200, ['X-Bad' => "one\r\ntwo"]) + : new Response($request->getPathInfo())); + + $this->assertAnsweredWith500AndTheNextServed($failing, $failing->exchange, $kernel); + } + + /** + * @param FakeExchange $answer What $failing records the answer in. + */ + private function assertAnsweredWith500AndTheNextServed(Exchange $failing, FakeExchange $answer, TestKernel $kernel): void + { + $next = FakeExchange::for('/next'); + + $this->serve($kernel, new FakeHttpDispatcher($failing, $next)); + + Assert::same($answer->status, 500); + Assert::true($answer->isFinalized()); + Assert::same($next->getBody(), '/next'); + } + private function serve(TestKernel $kernel, FakeHttpDispatcher $dispatcher): void { (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 1a032d4..691ee0d 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -16,10 +16,12 @@ use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Tests\Support\TestKernel; use Rapira\UnixAddress; +use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Testo\Assert; +use Testo\Skip; use Testo\Test; /** @@ -152,6 +154,19 @@ public function urlEncodedFormIsParsedIntoNestedFields(): void ]); } + #[Skip('known bug: a form content type in another letter case is not parsed')] + public function urlEncodedFormIsParsedWhateverTheContentTypeCase(): void + { + $request = $this->serveOne(FakeExchange::for( + '/form', + 'POST', + ['content-type' => ['Application/X-WWW-Form-Urlencoded']], + 'user[name]=Ada', + )); + + Assert::same($request->request->all(), ['user' => ['name' => 'Ada']]); + } + public function multipartFieldsAndFilesKeepTheirNestingAndUploadCopiesAreRemovedAfterwards(): void { $source = self::file('upload-body'); @@ -234,6 +249,51 @@ public function uploadMovedByTheApplicationSurvivesTheCleanup(): void @\unlink($source); } + #[Skip('known bug: dispatcher cookies are not URL-decoded')] + public function cookieValuesAreUrlDecoded(): void + { + $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['sid=a%3Ab%3D; plain=a%20b']])); + + Assert::same($request->cookies->all(), ['sid' => 'a:b=', 'plain' => 'a b']); + } + + #[Skip('known bug: with duplicate dispatcher cookie names the last one wins instead of the first')] + public function firstOfDuplicateCookieNamesWins(): void + { + $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['a=1; a=2', 'a=3']])); + + Assert::same($request->cookies->get('a'), '1'); + } + + #[Skip('known bug: several Cookie headers are joined with ", " instead of "; "')] + public function severalCookieHeadersAreJoinedAsOneCookieList(): void + { + $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['a=1', 'b=2']])); + + Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); + Assert::same($request->headers->get('cookie'), 'a=1; b=2'); + Assert::same($request->server->get('HTTP_COOKIE'), 'a=1; b=2'); + } + + #[Skip('known bug: dispatcher cookies are not URL-decoded, so a cookie Symfony set does not round-trip')] + public function cookieSetBySymfonyRoundTrips(): void + { + $value = 'a:b= c;d/é'; + $set = FakeExchange::for('/set'); + $this->serve(new TestKernel(static function () use ($value): Response { + $response = new Response(); + $response->headers->setCookie(new Cookie('sid', $value)); + + return $response; + }), $set); + FakeRuntime::reset(); + $cookie = \explode(';', $set->headers['Set-Cookie'][0], 2)[0]; + + $request = $this->serveOne(FakeExchange::for('/read', headers: ['cookie' => [$cookie]])); + + Assert::same($request->cookies->get('sid'), $value); + } + /** * @param non-empty-string $method * @param non-empty-string $uri diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php index e959827..2498a2a 100644 --- a/tests/Feature/DispatcherResponseTest.php +++ b/tests/Feature/DispatcherResponseTest.php @@ -11,6 +11,7 @@ use Rapira\Sdk\Testing\Double\Http\FakeExchange; use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; +use Rapira\Symfony\Tests\Support\ReadCountingStream; use Rapira\Symfony\Tests\Support\ScriptedExchange; use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -18,6 +19,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; +use Testo\Lifecycle\AfterTest; +use Testo\Skip; use Testo\Test; /** @@ -28,6 +31,12 @@ final class DispatcherResponseTest { use FakeRuntimeLifecycle; + #[AfterTest] + public function unregisterStreamWrapper(): void + { + ReadCountingStream::unregister(); + } + public function ordinaryResponseIsWrittenWithRepeatedHeadersAndCookies(): void { $exchange = FakeExchange::for('/'); @@ -179,6 +188,26 @@ public function headRequestForAFileSendsItsLengthAndStillDeletesIt(): void Assert::false(\is_file($path)); } + #[Skip('known bug: a HEAD request reads the whole file of a BinaryFileResponse')] + public function headRequestForAFileDoesNotReadIt(): void + { + $path = self::file('0123456789'); + ReadCountingStream::register(); + $exchange = FakeExchange::for('/file', 'HEAD'); + + $this->serve(static fn(): Response => (new BinaryFileResponse( + ReadCountingStream::path($path), + headers: ['Content-Type' => 'application/octet-stream'], + ))->deleteFileAfterSend(), $exchange); + + Assert::same(ReadCountingStream::$bytesRead, 0); + Assert::same($exchange->status, 200); + Assert::same($exchange->headers['Content-Length'], ['10']); + Assert::same(self::deliveredBody($exchange), ''); + Assert::true($exchange->isFinalized()); + Assert::false(\is_file($path)); + } + public function emptyFileResponseStillDeletesTheFile(): void { foreach ([204, 304] as $status) { diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index db03d9f..c1b3baf 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\Response; use Testo\Assert; use Testo\Expect; +use Testo\Skip; use Testo\Test; /** @@ -92,6 +93,19 @@ public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): Assert::same($runtime->outputs, ['/one', '/two', '/three']); } + #[Skip('known bug: the request served by the last handle_request() call is never terminated')] + public function requestServedByTheLastHandleRequestCallIsTerminated(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/one') + ->queue('GET', '/last'); + $kernel = new TestKernel(); + + $this->run($runtime, $kernel); + + Assert::same($kernel->events, ['handle:/one', 'terminate:/one', 'handle:/last', 'terminate:/last']); + } + public function terminateFailurePropagatesBeforeTheNextRequest(): never { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) diff --git a/tests/Support/ReadCountingStream.php b/tests/Support/ReadCountingStream.php new file mode 100644 index 0000000..1bb1977 --- /dev/null +++ b/tests/Support/ReadCountingStream.php @@ -0,0 +1,107 @@ +handle = $handle; + + return true; + } + + public function stream_read(int $count): string|false + { + $data = \fread($this->handle, $count); + self::$bytesRead += \strlen((string) $data); + + return $data; + } + + public function stream_eof(): bool + { + return \feof($this->handle); + } + + public function stream_seek(int $offset, int $whence): bool + { + return \fseek($this->handle, $offset, $whence) === 0; + } + + public function stream_tell(): int + { + return (int) \ftell($this->handle); + } + + public function stream_stat(): array|false + { + return \fstat($this->handle); + } + + public function stream_close(): void + { + \fclose($this->handle); + } + + public function url_stat(string $path, int $flags): array|false + { + $local = self::local($path); + + return \file_exists($local) ? \stat($local) : false; + } + + public function unlink(string $path): bool + { + return \unlink(self::local($path)); + } + + private static function local(string $path): string + { + return \substr($path, \strlen(self::SCHEME . '://')); + } +} From 96e9cc34854a7c452e42492ffb2245e9dcc8d827 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 23:25:07 +0400 Subject: [PATCH 18/42] test: serve a flushing StreamedResponse through a live Rapira Under the Rapira SAPI a worker starts without an output buffer, so ob_flush() inside a StreamedResponse callback raises a notice that Symfony's error handler turns into an exception, and the client gets the first chunk followed by an error page. Under the cli SAPI of the Feature suite the same callback finds a buffer and passes, so only a live server shows it. Worker and Classic modes are skipped as a known bug; Dispatcher mode buffers the callback itself and stays active. The pool runs a single process so the request after the stream reaches the same worker, which the boot id route then confirms. Assisted-By: Claude Opus 5.5 --- tests/Acceptance/ClassicModeTest.php | 7 +++++++ tests/Acceptance/DispatcherModeTest.php | 5 +++++ tests/Acceptance/Support/ServerRequests.php | 18 ++++++++++++++++++ tests/Acceptance/WorkerModeTest.php | 7 +++++++ tests/App/Kernel.php | 16 ++++++++++++++++ tests/App/rapira.toml | 2 ++ 6 files changed, 55 insertions(+) diff --git a/tests/Acceptance/ClassicModeTest.php b/tests/Acceptance/ClassicModeTest.php index 1aee7dd..9d2e08d 100644 --- a/tests/Acceptance/ClassicModeTest.php +++ b/tests/Acceptance/ClassicModeTest.php @@ -7,6 +7,7 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; +use Testo\Skip; use Testo\Test; /** @@ -20,6 +21,12 @@ final class ClassicModeTest private const ADDRESS = '127.0.0.1:8081'; + #[Skip('known bug: ob_flush() in a StreamedResponse callback fails without an output buffer and cuts the response')] + public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void + { + $this->assertFlushingStreamArrivesWhole(sameWorker: false); + } + protected function mode(): Mode { return Mode::Classic; diff --git a/tests/Acceptance/DispatcherModeTest.php b/tests/Acceptance/DispatcherModeTest.php index 7b9595c..9f0b3e6 100644 --- a/tests/Acceptance/DispatcherModeTest.php +++ b/tests/Acceptance/DispatcherModeTest.php @@ -20,6 +20,11 @@ final class DispatcherModeTest private const ADDRESS = '127.0.0.1:8083'; + public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void + { + $this->assertFlushingStreamArrivesWhole(sameWorker: true); + } + protected function mode(): Mode { return Mode::Dispatcher; diff --git a/tests/Acceptance/Support/ServerRequests.php b/tests/Acceptance/Support/ServerRequests.php index 6e92ce3..dba9b6c 100644 --- a/tests/Acceptance/Support/ServerRequests.php +++ b/tests/Acceptance/Support/ServerRequests.php @@ -31,6 +31,24 @@ public function processRunsInTheRequestedMode(): void abstract protected function mode(): Mode; + /** + * A response that flushes from inside its callback arrives whole, and the application goes on serving. + * + * @param bool $sameWorker Also require the next request to reach the same boot of the application, + * which only a resident mode keeps. + */ + private function assertFlushingStreamArrivesWhole(bool $sameWorker): void + { + [, $boot] = $this->request('/boot'); + + [$status, $body] = $this->request('/stream'); + + Assert::same($status, 200); + Assert::same($body, 'onetwo'); + Assert::same($this->request('/'), [200, 'OK']); + $sameWorker and Assert::same($this->request('/boot')[1], $boot); + } + /** * @return array{0: int, 1: string} The response status code and body. */ diff --git a/tests/Acceptance/WorkerModeTest.php b/tests/Acceptance/WorkerModeTest.php index 68fb921..2b7fe66 100644 --- a/tests/Acceptance/WorkerModeTest.php +++ b/tests/Acceptance/WorkerModeTest.php @@ -7,6 +7,7 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; +use Testo\Skip; use Testo\Test; /** @@ -20,6 +21,12 @@ final class WorkerModeTest private const ADDRESS = '127.0.0.1:8082'; + #[Skip('known bug: ob_flush() in a StreamedResponse callback fails without an output buffer and cuts the response')] + public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void + { + $this->assertFlushingStreamArrivesWhole(sameWorker: true); + } + protected function mode(): Mode { return Mode::Worker; diff --git a/tests/App/Kernel.php b/tests/App/Kernel.php index d222cbd..047c36a 100644 --- a/tests/App/Kernel.php +++ b/tests/App/Kernel.php @@ -6,6 +6,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\HttpKernelInterface; /** @@ -13,12 +14,27 @@ */ final class Kernel implements HttpKernelInterface { + /** Tells one boot of the application from the next: a resident worker keeps it across requests. */ + private readonly string $bootId; + + public function __construct() + { + $this->bootId = \bin2hex(\random_bytes(8)); + } + #[\Override] public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response { return match ($request->getPathInfo()) { '/' => new Response('OK'), '/mode' => new Response(\strtolower(\Rapira\get_mode()->name)), + '/boot' => new Response($this->bootId), + '/stream' => new StreamedResponse(static function (): void { + echo 'one'; + \ob_flush(); + \flush(); + echo 'two'; + }), default => new Response('Not Found', Response::HTTP_NOT_FOUND), }; } diff --git a/tests/App/rapira.toml b/tests/App/rapira.toml index d64e33e..7a3534a 100644 --- a/tests/App/rapira.toml +++ b/tests/App/rapira.toml @@ -5,3 +5,5 @@ listen = "127.0.0.1:8080" entrypoint = "public/index.php" # "dispatcher", "worker" or "classic": the same public/index.php serves all three. mode = "worker" +# One process, so consecutive requests reach the same worker. +processes = 1 From af198824c6f21ac3b55b98990d12cba7a020295d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:18:53 +0400 Subject: [PATCH 19/42] test: terminate every worker request under the host-parity double chore: require rapira/testing ^0.1.7 The last request went unterminated only because the 0.1.6 double returned false together with it; like the host, 0.1.7 answers true for every served request and false from a separate empty call. Assisted-By: Claude Opus 5.5 --- composer.json | 2 +- tests/Feature/WorkerModeTest.php | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 37b0303..0edd08a 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "ext-curl": "*", "fidry/cpu-core-counter": "^1.2", "llm/skills": "^1.14", - "rapira/testing": "^0.1.6", + "rapira/testing": "^0.1.7", "roxblnfk/unpoly": "^1.8", "shipmonk/composer-dependency-analyser": "^1.8.4", "spiral/code-style": "^2.3.1", diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index c1b3baf..fc335a2 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpFoundation\Response; use Testo\Assert; use Testo\Expect; -use Testo\Skip; use Testo\Test; /** @@ -86,14 +85,14 @@ public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): $this->run($runtime, $kernel); - Assert::same(\array_slice($kernel->events, 0, 6), [ + Assert::same($kernel->events, [ 'handle:/one', 'terminate:/one', 'sent:1 served:1', 'handle:/two', 'terminate:/two', 'sent:2 served:2', + 'handle:/three', 'terminate:/three', 'sent:3 served:3', ]); Assert::same($runtime->outputs, ['/one', '/two', '/three']); } - #[Skip('known bug: the request served by the last handle_request() call is never terminated')] public function requestServedByTheLastHandleRequestCallIsTerminated(): void { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) From 238c973639dd89e8b6da83d710856235068d5390 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:31:30 +0400 Subject: [PATCH 20/42] refactor: drop the test seams from the Runtime The protected getMode() and getHttpDispatcher() hooks, the RequestLoop interface pair and the injectable boot $_SERVER of the request factory existed only so tests could replace the host; the Feature suite now scripts the host through the rapira/contract stubs instead, so the Runtime calls the Rapira functions directly and the runners build their own collaborators. The runners are internal and move into Internal\ next to them, which leaves Runtime the only public class. The options docblock no longer lists worker_loop_max, which only FrankenPHP's runner reads. Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRequestFactory.php | 10 ++--- src/{ => Internal}/DispatcherRunner.php | 18 ++++----- src/Internal/NativeRequestLoop.php | 22 ----------- src/Internal/RequestLoop.php | 19 --------- src/{ => Internal}/WorkerRunner.php | 6 +-- src/Runtime.php | 47 +++++++---------------- 6 files changed, 27 insertions(+), 95 deletions(-) rename src/{ => Internal}/DispatcherRunner.php (82%) delete mode 100644 src/Internal/NativeRequestLoop.php delete mode 100644 src/Internal/RequestLoop.php rename src/{ => Internal}/WorkerRunner.php (86%) diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index ac5242c..71ee152 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -46,14 +46,12 @@ private Psr17Factory $psr17Factory; private HttpFoundationFactory $httpFoundationFactory; - /** - * @param array|null $server - */ - public function __construct(?array $server = null) + public function __construct() { - $sourceServer = $server ?? $_SERVER; + /** @var array $server */ + $server = $_SERVER; $this->server = \array_filter( - $sourceServer, + $server, static function (mixed $key): bool { if (\in_array($key, self::NON_HTTP_BOOT_SERVER_KEYS, true)) { return true; diff --git a/src/DispatcherRunner.php b/src/Internal/DispatcherRunner.php similarity index 82% rename from src/DispatcherRunner.php rename to src/Internal/DispatcherRunner.php index 3deb3e7..f8be441 100644 --- a/src/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -2,13 +2,10 @@ declare(strict_types=1); -namespace Rapira\Symfony; +namespace Rapira\Symfony\Internal; use Rapira\Exception\ClosedException; use Rapira\Http\HttpDispatcher; -use Rapira\Symfony\Internal\DispatcherRequestFactory; -use Rapira\Symfony\Internal\ExchangeResponseEmitter; -use Rapira\Symfony\Internal\ResponseDiscardedException; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; @@ -18,15 +15,16 @@ */ final readonly class DispatcherRunner implements RunnerInterface { - /** - * @psalm-mutation-free - */ + private DispatcherRequestFactory $requestFactory; + private ExchangeResponseEmitter $responseEmitter; + public function __construct( private HttpKernelInterface $kernel, private HttpDispatcher $dispatcher, - private DispatcherRequestFactory $requestFactory, - private ExchangeResponseEmitter $responseEmitter, - ) {} + ) { + $this->requestFactory = new DispatcherRequestFactory(); + $this->responseEmitter = new ExchangeResponseEmitter(); + } #[\Override] public function run(): int diff --git a/src/Internal/NativeRequestLoop.php b/src/Internal/NativeRequestLoop.php deleted file mode 100644 index 3c92bae..0000000 --- a/src/Internal/NativeRequestLoop.php +++ /dev/null @@ -1,22 +0,0 @@ -requestLoop->handle(function () use ($server, &$request, &$response): bool { + $handled = \Rapira\handle_request(function () use ($server, &$request, &$response): bool { $_SERVER += $server; $request = Request::createFromGlobals(); diff --git a/src/Runtime.php b/src/Runtime.php index d7bb4c6..a56e927 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -6,14 +6,15 @@ use Rapira\Http\HttpDispatcher; use Rapira\Mode; -use Rapira\Symfony\Internal\DispatcherRequestFactory; -use Rapira\Symfony\Internal\ExchangeResponseEmitter; -use Rapira\Symfony\Internal\NativeRequestLoop; +use Rapira\Symfony\Internal\DispatcherRunner; +use Rapira\Symfony\Internal\WorkerRunner; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Runtime\RunnerInterface; use Symfony\Component\Runtime\SymfonyRuntime; /** + * Symfony Runtime that serves an HTTP kernel in the mode the Rapira host launched the process in. + * * @api * @psalm-consistent-constructor */ @@ -38,14 +39,13 @@ class Runtime extends SymfonyRuntime * project_dir_var?: string|false, * dotenv_overload?: ?bool, * dotenv_extra_paths?: ?string[], - * worker_loop_max?: int, * } $options */ public function __construct(array $options = []) { - $this->mode = $this->getMode(); + $this->mode = \Rapira\get_mode(); - if ($this->mode === Mode::Worker || $this->mode === Mode::Dispatcher) { + if ($this->mode !== Mode::Classic) { $_SERVER['APP_RUNTIME_MODE'] = 'web=1&worker=1'; } @@ -61,39 +61,18 @@ public function getRunner(?object $application): RunnerInterface return match ($this->mode) { Mode::Classic => parent::getRunner($application), - Mode::Worker => new WorkerRunner($application, new NativeRequestLoop()), - Mode::Dispatcher => new DispatcherRunner( - $application, - $this->getHttpDispatcher(), - new DispatcherRequestFactory(), - new ExchangeResponseEmitter(), - ), + Mode::Worker => new WorkerRunner($application), + Mode::Dispatcher => new DispatcherRunner($application, self::httpDispatcher()), }; } - /** - * @psalm-suppress MissingPureAnnotation - * @psalm-suppress UndefinedFunction The function is provided by the Rapira extension and its contract. - * @psalm-suppress MixedReturnStatement - */ - protected function getMode(): Mode - { - return \Rapira\get_mode(); - } - - /** - * @psalm-suppress MissingPureAnnotation - * @psalm-suppress UndefinedFunction The function is provided by the Rapira extension and its contract. - */ - protected function getHttpDispatcher(): HttpDispatcher + private static function httpDispatcher(): HttpDispatcher { $dispatcher = \Rapira\get_dispatcher(); - if (!$dispatcher instanceof HttpDispatcher) { - throw new \LogicException(\sprintf( - 'Rapira Dispatcher mode requires an HTTP dispatcher; got %s.', - $dispatcher::class, - )); - } + $dispatcher instanceof HttpDispatcher or throw new \LogicException(\sprintf( + 'Rapira Dispatcher mode requires an HTTP dispatcher; got %s.', + $dispatcher::class, + )); return $dispatcher; } From e9a8858f58e7c55e0559715840ceb3ca160f357b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:34:17 +0400 Subject: [PATCH 21/42] fix: send Worker and Classic responses inside an own output buffer The Rapira SAPI starts a request without an output buffer, so ob_flush() in a StreamedResponse callback raised a notice that Symfony turned into an exception, and the client got the first chunk followed by an error page. Both modes now send the response inside a buffer of their own, with the 8 KiB chunk size output_buffering gives php-fpm, and close whatever buffers the body left open. Classic mode went through Symfony's HttpKernelRunner, which knows fastcgi_finish_request() but not rapira_finish_request(), so the client waited for terminate(). It gets a runner of its own that finishes the request before terminating, as Worker mode now does too. Outside Rapira the contract stub answers false and the runner finishes the request the way Symfony's own does, so php-fpm serving the same application needs no separate path. Assisted-By: Claude Opus 5.5 --- src/Internal/ClassicRunner.php | 47 ++++++++++++++++ src/Internal/SapiResponse.php | 40 ++++++++++++++ src/Internal/WorkerRunner.php | 3 +- src/Runtime.php | 3 +- tests/Acceptance/ClassicModeTest.php | 2 - tests/Acceptance/WorkerModeTest.php | 2 - tests/Feature/RuntimeTest.php | 83 +++++++++++++++++++++------- tests/Feature/WorkerModeTest.php | 35 ++++++++++++ 8 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 src/Internal/ClassicRunner.php create mode 100644 src/Internal/SapiResponse.php diff --git a/src/Internal/ClassicRunner.php b/src/Internal/ClassicRunner.php new file mode 100644 index 0000000..4d2048f --- /dev/null +++ b/src/Internal/ClassicRunner.php @@ -0,0 +1,47 @@ +kernel->handle($request); + + SapiResponse::send($response); + // The client has its answer while terminate runs. Outside Rapira the stub answers false, and the + // request is finished the way Symfony's own runner does it, which keeps it open in debug. + if (!rapira_finish_request() && !$this->debug) { + \function_exists('fastcgi_finish_request') and fastcgi_finish_request(); + \function_exists('litespeed_finish_request') and litespeed_finish_request(); + } + + if ($this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($request, $response); + } + + return 0; + } +} diff --git a/src/Internal/SapiResponse.php b/src/Internal/SapiResponse.php new file mode 100644 index 0000000..4d466a2 --- /dev/null +++ b/src/Internal/SapiResponse.php @@ -0,0 +1,40 @@ +send(false); + } finally { + // Buffers the body opened and left behind are flushed as the SAPI would at the end of a request; + // one that cannot be removed stays until the request ends. + while (\ob_get_level() > $level && @\ob_end_flush()); + } + } +} diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index 6b7b992..e4f7673 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -42,7 +42,8 @@ public function run(): int $request = Request::createFromGlobals(); $response = $this->kernel->handle($request); - $response->send(); + SapiResponse::send($response); + rapira_finish_request(); return true; }); diff --git a/src/Runtime.php b/src/Runtime.php index a56e927..3340437 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -6,6 +6,7 @@ use Rapira\Http\HttpDispatcher; use Rapira\Mode; +use Rapira\Symfony\Internal\ClassicRunner; use Rapira\Symfony\Internal\DispatcherRunner; use Rapira\Symfony\Internal\WorkerRunner; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -60,7 +61,7 @@ public function getRunner(?object $application): RunnerInterface } return match ($this->mode) { - Mode::Classic => parent::getRunner($application), + Mode::Classic => new ClassicRunner($application, (bool) ($this->options['debug'] ?? false)), Mode::Worker => new WorkerRunner($application), Mode::Dispatcher => new DispatcherRunner($application, self::httpDispatcher()), }; diff --git a/tests/Acceptance/ClassicModeTest.php b/tests/Acceptance/ClassicModeTest.php index 9d2e08d..3589bcb 100644 --- a/tests/Acceptance/ClassicModeTest.php +++ b/tests/Acceptance/ClassicModeTest.php @@ -7,7 +7,6 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; -use Testo\Skip; use Testo\Test; /** @@ -21,7 +20,6 @@ final class ClassicModeTest private const ADDRESS = '127.0.0.1:8081'; - #[Skip('known bug: ob_flush() in a StreamedResponse callback fails without an output buffer and cuts the response')] public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void { $this->assertFlushingStreamArrivesWhole(sameWorker: false); diff --git a/tests/Acceptance/WorkerModeTest.php b/tests/Acceptance/WorkerModeTest.php index 2b7fe66..0263eae 100644 --- a/tests/Acceptance/WorkerModeTest.php +++ b/tests/Acceptance/WorkerModeTest.php @@ -7,7 +7,6 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; -use Testo\Skip; use Testo\Test; /** @@ -21,7 +20,6 @@ final class WorkerModeTest private const ADDRESS = '127.0.0.1:8082'; - #[Skip('known bug: ob_flush() in a StreamedResponse callback fails without an output buffer and cuts the response')] public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void { $this->assertFlushingStreamArrivesWhole(sameWorker: true); diff --git a/tests/Feature/RuntimeTest.php b/tests/Feature/RuntimeTest.php index 6cd7763..ca12db7 100644 --- a/tests/Feature/RuntimeTest.php +++ b/tests/Feature/RuntimeTest.php @@ -9,7 +9,10 @@ use Rapira\Sdk\Testing\Double\Http\FakeExchange; use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; +use Rapira\Symfony\Runtime; use Rapira\Symfony\Tests\Support\TestKernel; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; use Testo\Test; @@ -50,33 +53,39 @@ public function dispatcherModeMarksTheProcessAsAWorkerForTheApplication(): void Assert::same($kernel->requests[0]->server->get('APP_RUNTIME_MODE'), 'web=1&worker=1'); } - public function classicModeServesOneRequestFromTheSuperglobalsThroughSymfony(): void + public function classicModeServesOneRequestFromTheSuperglobalsAndFinishesItBeforeTerminating(): void { unset($_SERVER['APP_RUNTIME_MODE']); - (new FakeRuntime(Mode::Classic))->install(); - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => '/classic', - 'HTTP_HOST' => 'localhost', - 'SCRIPT_NAME' => '/index.php', - ] + $_SERVER; - $kernel = new TestKernel(); + $host = (new FakeRuntime(Mode::Classic))->install(); + $kernel = new TestKernel( + static fn(): Response => new StreamedResponse(static function (): void { + echo 'one'; + \ob_flush(); + \ob_start(); + echo 'two'; + }), + static function () use ($host, &$kernel): void { + $kernel->events[] = 'finished:' . $host->finishedRequests; + }, + ); $runtime = self::runtime(); Assert::false(isset($_SERVER['APP_RUNTIME_MODE'])); - $output = ''; - // Symfony's runner closes every output buffer it may remove, so the output is taken as it is flushed. - \ob_start(static function (string $chunk) use (&$output): string { - $output .= $chunk; + [$result, $output, $levelChange] = self::serveClassic($runtime, $kernel); - return ''; - }); - $level = \ob_get_level(); - try { - $result = $runtime->getRunner($kernel)->run(); - } finally { - \ob_get_level() === $level and \ob_end_flush(); - } + Assert::same($result, 0); + Assert::same($output, 'onetwo'); + Assert::same($levelChange, 0); + Assert::same($kernel->events, ['handle:/classic', 'terminate:/classic', 'finished:1']); + } + + public function classicModeOutsideRapiraServesTheRequestAsWell(): void + { + // No double installed: the contract stubs answer as outside Rapira, `rapira_finish_request()` false. + FakeRuntime::reset(); + $kernel = new TestKernel(); + + [$result, $output] = self::serveClassic(self::runtime(), $kernel); Assert::same($result, 0); Assert::same($output, '/classic'); @@ -95,4 +104,36 @@ public function nonHttpApplicationRunsOnceInResidentModes(): void Assert::same(self::runtime()->getRunner(static fn(): int => 17)->run(), 17); } } + + /** + * Serves `GET /classic` from the superglobals and takes the output as it is flushed. + * + * @return array{int, string, int} The exit code, the output, and the change of the output buffer level. + */ + private static function serveClassic(Runtime $runtime, TestKernel $kernel): array + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/classic', + 'HTTP_HOST' => 'localhost', + 'SCRIPT_NAME' => '/index.php', + ] + $_SERVER; + $output = ''; + \ob_start(static function (string $chunk) use (&$output): string { + $output .= $chunk; + + return ''; + }); + $level = \ob_get_level(); + try { + $result = $runtime->getRunner($kernel)->run(); + $levelChange = \ob_get_level() - $level; + } finally { + while (\ob_get_level() >= $level) { + \ob_end_flush(); + } + } + + return [$result, $output, $levelChange]; + } } diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index fc335a2..55650c1 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -11,6 +11,7 @@ use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; use Testo\Expect; use Testo\Test; @@ -93,6 +94,40 @@ public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): Assert::same($runtime->outputs, ['/one', '/two', '/three']); } + public function responseIsFinishedBeforeTheRequestTerminates(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/one') + ->queue('GET', '/two'); + $kernel = new TestKernel(terminate: static function () use ($runtime, &$kernel): void { + $kernel->events[] = 'finished:' . $runtime->finishedRequests; + }); + + $this->run($runtime, $kernel); + + Assert::same($kernel->events, [ + 'handle:/one', 'terminate:/one', 'finished:1', + 'handle:/two', 'terminate:/two', 'finished:2', + ]); + } + + public function flushingStreamedResponseArrivesWholeAndLeavesNoBufferBehind(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/stream'); + $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function (): void { + echo 'one'; + \ob_flush(); + \ob_start(); + echo 'two'; + })); + $level = \ob_get_level(); + + $this->run($runtime, $kernel); + + Assert::same($runtime->outputs, ['onetwo']); + Assert::same(\ob_get_level(), $level); + } + public function requestServedByTheLastHandleRequestCallIsTerminated(): void { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) From 6e1e7c47238fdd835210ff03c58fd4e4d1a4c4c3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:35:45 +0400 Subject: [PATCH 22/42] fix: release each worker request before collecting cycles The loop kept the last request and response in its own variables while gc_collect_cycles() ran, so every request's graph survived until the next one replaced it. Serving a request now happens in a method of its own whose frame owns them. Terminate still runs after handle_request() returns: the response has been finished by then, and a terminate failure ends the worker instead of reaching the host as a failure of a request it already answered. The host builds $_SERVER from the request alone, so the boot-time variables Dotenv set, APP_ENV and APP_SECRET among them, are still added to every request. Only HTTP_* entries were filtered out of them, so a boot-time HTTPS, QUERY_STRING or REMOTE_ADDR leaked into requests that lacked one; the worker now applies the same filter the dispatcher does. Assisted-By: Claude Opus 5.5 --- src/Internal/BootServer.php | 85 +++++++++++++++++++++++ src/Internal/DispatcherRequestFactory.php | 47 +------------ src/Internal/WorkerRunner.php | 62 ++++++++++------- tests/Feature/WorkerModeTest.php | 22 ++++++ 4 files changed, 144 insertions(+), 72 deletions(-) create mode 100644 src/Internal/BootServer.php diff --git a/src/Internal/BootServer.php b/src/Internal/BootServer.php new file mode 100644 index 0000000..b728fab --- /dev/null +++ b/src/Internal/BootServer.php @@ -0,0 +1,85 @@ + $server The `$_SERVER` of the boot. + * + * @return array + * + * @psalm-pure + */ + public static function variables(array $server): array + { + $variables = []; + /** @psalm-suppress MixedAssignment */ + foreach ($server as $key => $value) { + if (\is_string($key) && self::isBootVariable($key)) { + $variables[$key] = $value; + } + } + + return $variables; + } + + /** + * @psalm-pure + */ + private static function isBootVariable(string $key): bool + { + if (\in_array($key, self::SCRIPT_KEYS, true)) { + return true; + } + if (\in_array($key, self::REQUEST_KEYS, true)) { + return false; + } + + foreach (self::REQUEST_PREFIXES as $prefix) { + if (\str_starts_with($key, $prefix)) { + return false; + } + } + + return true; + } +} diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 71ee152..48a473f 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -17,29 +17,6 @@ */ final readonly class DispatcherRequestFactory { - private const NON_HTTP_BOOT_SERVER_KEYS = [ - 'DOCUMENT_ROOT', - 'PATH_TRANSLATED', - 'PHP_SELF', - 'SCRIPT_FILENAME', - 'SCRIPT_NAME', - ]; - private const REQUEST_SERVER_PREFIXES = [ - 'AUTH_', - 'CONTENT_', - 'HTTP_', - 'REMOTE_', - 'REQUEST_', - 'SERVER_', - ]; - private const REQUEST_SERVER_KEYS = [ - 'GATEWAY_INTERFACE', - 'HTTPS', - 'ORIG_PATH_INFO', - 'PATH_INFO', - 'REDIRECT_STATUS', - ]; - /** @var array */ private array $server; @@ -48,29 +25,7 @@ public function __construct() { - /** @var array $server */ - $server = $_SERVER; - $this->server = \array_filter( - $server, - static function (mixed $key): bool { - if (\in_array($key, self::NON_HTTP_BOOT_SERVER_KEYS, true)) { - return true; - } - if (\in_array($key, self::REQUEST_SERVER_KEYS, true)) { - return false; - } - - foreach (self::REQUEST_SERVER_PREFIXES as $prefix) { - if (\str_starts_with($key, $prefix)) { - return false; - } - } - - return true; - }, - \ARRAY_FILTER_USE_KEY, - ); - + $this->server = BootServer::variables($_SERVER); $this->psr17Factory = new Psr17Factory(); $this->httpFoundationFactory = new HttpFoundationFactory(); } diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index e4f7673..bd80882 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -5,11 +5,14 @@ namespace Rapira\Symfony\Internal; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; /** + * Serves Rapira's Worker mode: the loop over `Rapira\handle_request()` with one resident kernel. + * * @internal */ final readonly class WorkerRunner implements RunnerInterface @@ -26,39 +29,46 @@ public function run(): int { \ignore_user_abort(true); - /** @var array $server */ - $server = \array_filter( - $_SERVER, - static fn(mixed $key): bool => !\str_starts_with((string) $key, 'HTTP_'), - \ARRAY_FILTER_USE_KEY, - ); - - while (true) { - $request = null; - $response = null; + $boot = BootServer::variables($_SERVER); + while ($this->serveNext($boot)) { + \gc_collect_cycles(); + } - $handled = \Rapira\handle_request(function () use ($server, &$request, &$response): bool { - $_SERVER += $server; + return 0; + } - $request = Request::createFromGlobals(); - $response = $this->kernel->handle($request); - SapiResponse::send($response); - rapira_finish_request(); + /** + * Serves one request, if the host has one, and terminates it. The request and the response die with + * this frame, so the cycle collection that follows can free them. + * + * @param array $boot + * + * @return bool False once the host hands out no more requests. + */ + private function serveNext(array $boot): bool + { + /** @var Request|null $request */ + $request = null; + /** @var Response|null $response */ + $response = null; - return true; - }); + $served = \Rapira\handle_request(function () use ($boot, &$request, &$response): bool { + $_SERVER += $boot; - if (!$handled) { - break; - } + $request = Request::createFromGlobals(); + $response = $this->kernel->handle($request); + SapiResponse::send($response); + rapira_finish_request(); - if ($this->kernel instanceof TerminableInterface && $request !== null && $response !== null) { - $this->kernel->terminate($request, $response); - } + return true; + }); - \gc_collect_cycles(); + // Outside the handler: the host would answer a failure thrown in it with a 500 for a request it has + // already sent, and keep serving with a kernel that failed to terminate. + if ($request !== null && $response !== null && $this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($request, $response); } - return 0; + return $served; } } diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index 55650c1..d7feffe 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -74,6 +74,28 @@ public function servesEveryRequestWithFreshGlobalsAndOneResidentKernel(): void Assert::notSame($kernel->requests[0], $kernel->requests[1]); } + public function requestMetadataOfTheBootIsNotCarriedIntoRequests(): void + { + $_SERVER = [ + 'HTTPS' => 'on', + 'HTTP_X_BOOT' => 'boot', + 'QUERY_STRING' => 'boot=1', + 'REMOTE_ADDR' => '192.0.2.1', + 'SCRIPT_FILENAME' => '/app/public/index.php', + ] + $_SERVER; + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/plain'); + $kernel = new TestKernel(); + + $this->run($runtime, $kernel); + + $request = $kernel->requests[0]; + Assert::false($request->isSecure()); + Assert::null($request->headers->get('x-boot')); + Assert::null($request->getQueryString()); + Assert::same($request->getClientIp(), '127.0.0.1'); + Assert::same($request->server->get('SCRIPT_FILENAME'), '/app/public/index.php'); + } + public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): void { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) From aa5b4de0b28f0f42f5fae5491db345d9482fec04 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:36:31 +0400 Subject: [PATCH 23/42] fix: refuse a non-HttpKernel application in Worker and Dispatcher modes Such an application went through Symfony's own runners, which run it once and return, so a resident worker exited after its first pass without ever serving a request. Getting the runner now fails with a LogicException that names the mode and the application type; Classic mode still hands it to Symfony. Assisted-By: Claude Opus 5.5 --- src/Runtime.php | 8 ++++++++ tests/Feature/RuntimeTest.php | 36 ++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Runtime.php b/src/Runtime.php index 3340437..144df35 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -57,6 +57,14 @@ public function __construct(array $options = []) public function getRunner(?object $application): RunnerInterface { if (!$application instanceof HttpKernelInterface) { + // A resident process keeps serving only through a kernel; anything else would run once and exit. + $this->mode === Mode::Classic or throw new \LogicException(\sprintf( + 'Rapira %s mode serves only an %s application; got %s.', + $this->mode->name, + HttpKernelInterface::class, + \get_debug_type($application), + )); + return parent::getRunner($application); } diff --git a/tests/Feature/RuntimeTest.php b/tests/Feature/RuntimeTest.php index ca12db7..b9fef65 100644 --- a/tests/Feature/RuntimeTest.php +++ b/tests/Feature/RuntimeTest.php @@ -13,7 +13,9 @@ use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Testo\Assert; +use Testo\Expect; use Testo\Test; /** @@ -92,17 +94,33 @@ public function classicModeOutsideRapiraServesTheRequestAsWell(): void Assert::same($kernel->events, ['handle:/classic', 'terminate:/classic']); } - /** - * Pins the current delegation to Symfony; whether a resident mode should instead keep such an - * application alive is still open. - */ - public function nonHttpApplicationRunsOnceInResidentModes(): void + public function nonHttpApplicationIsRefusedInWorkerMode(): never { - foreach ([Mode::Worker, Mode::Dispatcher] as $mode) { - (new FakeRuntime($mode, new FakeHttpDispatcher()))->install(); + (new FakeRuntime(Mode::Worker))->install(); - Assert::same(self::runtime()->getRunner(static fn(): int => 17)->run(), 17); - } + Expect::exception(\LogicException::class)->withMessage( + 'Rapira Worker mode serves only an ' . HttpKernelInterface::class . ' application; got Closure.', + ); + + self::runtime()->getRunner(static fn(): int => 17); + } + + public function nonHttpApplicationIsRefusedInDispatcherMode(): never + { + (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher()))->install(); + + Expect::exception(\LogicException::class)->withMessage( + 'Rapira Dispatcher mode serves only an ' . HttpKernelInterface::class . ' application; got Closure.', + ); + + self::runtime()->getRunner(static fn(): int => 17); + } + + public function nonHttpApplicationRunsThroughSymfonyInClassicMode(): void + { + (new FakeRuntime(Mode::Classic))->install(); + + Assert::same(self::runtime()->getRunner(static fn(): int => 17)->run(), 17); } /** From 9f1ba1e52c75a165c0798268ce0e333adb848c36 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:40:29 +0400 Subject: [PATCH 24/42] fix: build the dispatcher request the way PHP fills the superglobals chore: drop nyholm/psr7 and symfony/psr-http-message-bridge The exchange went through a PSR-7 request on its way to HttpFoundation, and each hop lost a part of PHP's semantics: cookie values reached the application still URL-encoded, so a cookie Symfony set never read back the same; a repeated cookie name kept the last value where PHP keeps the first; several Cookie headers were joined with a comma; a form whose content type was not lowercase stayed unparsed; and file field names were nested by a parser of their own, without the mangling PHP applies to the fields next to them. The Symfony request is now built directly, with cookies, query, form and multipart names all parsed by parse_str() as in the laravel bridge, and form bodies parsed only for POST, PUT, PATCH and DELETE. The host deletes an upload's spool file once the exchange finalizes, which is before terminate(), so each file used to be copied. It is now renamed instead, which the contract names as the way to keep it, and still removed after terminate(). The raw method travelled in three places, a request attribute and two server keys, for the emitter to tell a wire HEAD from what Symfony's uppercasing getMethod() calls HEAD. The emitter now reads it from the exchange it writes to, and the application sees it where PHP puts it, in REQUEST_METHOD. Assisted-By: Claude Opus 5.5 --- composer-dependency-analyser.php | 5 +- composer.json | 2 - src/Internal/DispatcherRequest.php | 25 +- src/Internal/DispatcherRequestFactory.php | 411 +++++++++------------- src/Internal/ExchangeResponseEmitter.php | 9 +- tests/Feature/DispatcherRequestTest.php | 73 +++- 6 files changed, 249 insertions(+), 276 deletions(-) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 01a247b..5b4c026 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -3,12 +3,9 @@ declare(strict_types=1); use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; -use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return (new Configuration()) ->disableComposerAutoloadPathScan() ->setFileExtensions(['php']) ->addPathToScan(__DIR__ . '/src', isDev: false) - ->addPathToScan(__DIR__ . '/tests', isDev: true) - // Required through symfony/psr-http-message-bridge, whose PSR-7 conversion the runtime uses. - ->ignoreErrorsOnPackage('psr/http-message', [ErrorType::SHADOW_DEPENDENCY]); + ->addPathToScan(__DIR__ . '/tests', isDev: true); diff --git a/composer.json b/composer.json index 0edd08a..f304844 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,9 @@ ], "require": { "php": ">=8.4", - "nyholm/psr7": "^1.8", "rapira/contract": "^0.9", "symfony/http-foundation": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", - "symfony/psr-http-message-bridge": "^7.4 || ^8.0", "symfony/runtime": "^7.4 || ^8.0" }, "require-dev": { diff --git a/src/Internal/DispatcherRequest.php b/src/Internal/DispatcherRequest.php index fbcf05f..1be44a0 100644 --- a/src/Internal/DispatcherRequest.php +++ b/src/Internal/DispatcherRequest.php @@ -7,6 +7,8 @@ use Symfony\Component\HttpFoundation\Request; /** + * A Symfony request built from an exchange, with the uploaded files it owns until {@see cleanup()}. + * * @internal */ final class DispatcherRequest @@ -14,21 +16,28 @@ final class DispatcherRequest /** * @psalm-mutation-free * - * @param list $uploadPaths + * @param list $uploads */ public function __construct( public readonly Request $request, - private array $uploadPaths, + private array $uploads, ) {} - public function cleanup(): void + /** + * Removes the files the application left in place; one it moved is no longer there to remove. + * + * @param list $paths + */ + public static function removeFiles(array $paths): void { - foreach ($this->uploadPaths as $path) { - if (\is_file($path)) { - @\unlink($path); - } + foreach ($paths as $path) { + \is_file($path) and @\unlink($path); } + } - $this->uploadPaths = []; + public function cleanup(): void + { + self::removeFiles($this->uploads); + $this->uploads = []; } } diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 48a473f..fd82c25 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -4,351 +4,272 @@ namespace Rapira\Symfony\Internal; -use Nyholm\Psr7\Factory\Psr17Factory; -use Psr\Http\Message\ServerRequestInterface; use Rapira\Http\Multipart; use Rapira\Http\Request as RapiraRequest; +use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; /** + * Builds the Symfony request for an exchange the host hands out in Dispatcher mode. + * + * The request is filled the way PHP fills the superglobals in the other modes, so the application sees + * the same request whatever mode serves it: `$_SERVER`-style meta-variables, query and cookie values + * decoded and nested by PHP's own rules, form bodies parsed. The superglobals themselves stay untouched. + * * @internal */ final readonly class DispatcherRequestFactory { - /** @var array */ - private array $server; + /** Methods whose url-encoded body PHP or Symfony parse into the request parameters. */ + private const FORM_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; - private Psr17Factory $psr17Factory; - private HttpFoundationFactory $httpFoundationFactory; + /** @var array */ + private array $boot; public function __construct() { - $this->server = BootServer::variables($_SERVER); - $this->psr17Factory = new Psr17Factory(); - $this->httpFoundationFactory = new HttpFoundationFactory(); + $this->boot = BootServer::variables($_SERVER); } + /** + * @throws \RuntimeException An upload could not be taken over from the host. + */ public function create(RapiraRequest $source): DispatcherRequest { - $psrRequest = $this->createPsrRequest($this->normalizeRequest($source)); - $psrRequest = $psrRequest->withCookieParams($this->parseCookies($source->headers)); - $uploadPaths = []; + $headers = self::mergeHeaders($source->headers); + $query = \explode('?', $source->target, 2)[1] ?? ''; + $uploads = []; try { if ($source->body instanceof Multipart) { - [$files, $uploadPaths] = $this->createHttpFoundationFiles($source->body); - $psrRequest = $psrRequest->withUploadedFiles([]); + $post = self::parseFields($source->body); + $files = self::createFiles($source->body, $uploads); + $content = ''; } else { + $post = self::parseForm($source->method, $source->body, $headers); $files = []; + $content = $source->body; } - $request = $this->httpFoundationFactory->createRequest($psrRequest); - /** @psalm-suppress MixedArgumentTypeCoercion */ - $request->files->replace($files); + $request = new Request( + query: self::parseQuery($query), + request: $post, + cookies: self::parseCookies(\implode('; ', $headers['cookie'][1] ?? [])), + files: $files, + server: $this->createServerParams($source, $headers, $query), + content: $content, + ); } catch (\Throwable $exception) { - $this->cleanupUploadPaths($uploadPaths); + DispatcherRequest::removeFiles($uploads); throw $exception; } - $request->server->replace($request->server->all() + $this->server); - $request->server->set('REQUEST_URI', $source->target); - $request->server->set('RAPIRA_REQUEST_METHOD', $source->method); - if ($source->server instanceof InetAddress) { - $request->server->set('SERVER_ADDR', $source->server->ip); - $request->server->set('SERVER_PORT', $source->server->port); - } else { - $request->server->remove('SERVER_ADDR'); - $request->server->remove('SERVER_PORT'); + + // The server bag yields one comma-joined value per header; the exchange keeps every value apart. + foreach ($headers as $lower => [$name, $values]) { + $request->headers->set($name, $lower === 'cookie' ? \implode('; ', $values) : $values); } - $request->attributes->set('rapira.request_method', $source->method); - return new DispatcherRequest($request, $uploadPaths); + return new DispatcherRequest($request, $uploads); } - private function createPsrRequest(RapiraRequest $source): ServerRequestInterface + /** + * @param array> $headers + * + * @return array}> The values of every spelling + * of a name, under the spelling that came first. + * + * @psalm-pure + */ + private static function mergeHeaders(array $headers): array { - $request = $this->psr17Factory->createServerRequest( - $source->method, - $source->uri, - $this->serverParams($source), - ); - $request = $request->withProtocolVersion(\str_replace('HTTP/', '', $source->protocol)); - - foreach ($source->headers as $name => $values) { - $request = $request->withHeader($name, $values); - } - - $query = []; - \parse_str($request->getUri()->getQuery(), $query); - $request = $request->withQueryParams($query); - - if ($source->body instanceof Multipart) { - return $request - ->withBody($this->psr17Factory->createStream()) - ->withParsedBody($this->parseFields($source->body)); - } - - $request = $request->withBody($this->psr17Factory->createStream($source->body)); - if (\preg_match('~^application/x-www-form-urlencoded(?:$| |;)~', $request->getHeaderLine('content-type')) === 1) { - $parsed = []; - \parse_str($source->body, $parsed); - $request = $request->withParsedBody($parsed); + $merged = []; + foreach ($headers as $name => $values) { + $lower = \strtolower($name); + if (isset($merged[$lower])) { + $merged[$lower][1] = [...$merged[$lower][1], ...$values]; + } else { + $merged[$lower] = [$name, $values]; + } } - return $request; + return $merged; } /** - * @psalm-suppress MissingPureAnnotation + * @param array}> $headers * - * @return array + * @return array */ - private function serverParams(RapiraRequest $request): array + private static function parseForm(string $method, string $body, array $headers): array { - $params = [ - 'REQUEST_METHOD' => $request->method, - 'REQUEST_URI' => $request->target, - 'SERVER_PROTOCOL' => $request->protocol, - 'REQUEST_TIME' => (int) $request->receivedAt, - 'REQUEST_TIME_FLOAT' => $request->receivedAt, - ]; - - if ($request->authority !== null) { - $params['HTTP_HOST'] = $request->authority; - } - if ($request->tls !== null) { - $params['HTTPS'] = 'on'; - } - if ($request->remote instanceof InetAddress) { - $params['REMOTE_ADDR'] = $request->remote->ip; - $params['REMOTE_PORT'] = $request->remote->port; - } elseif ($request->remote->path !== null) { - $params['REMOTE_ADDR'] = $request->remote->path; - } - if ($request->server instanceof InetAddress) { - $params['SERVER_ADDR'] = $request->server->ip; - $params['SERVER_PORT'] = $request->server->port; - } elseif ($request->server->path !== null) { - $params['SERVER_ADDR'] = $request->server->path; + if (!\in_array($method, self::FORM_METHODS, true)) { + return []; } - foreach ($request->headers as $name => $values) { - $key = \strtoupper(\str_replace('-', '_', $name)); - if ($key !== 'CONTENT_TYPE' && $key !== 'CONTENT_LENGTH') { - $key = 'HTTP_' . $key; - } - $params[$key] = \implode(', ', $values); + $contentType = \strtolower(\implode(', ', $headers['content-type'][1] ?? [])); + if (\preg_match('~^application/x-www-form-urlencoded(?:$|[ ;])~', $contentType) !== 1) { + return []; } - return $params; + return self::parseQuery($body); } /** * @return array */ - private function parseFields(Multipart $multipart): array + private static function parseFields(Multipart $multipart): array { + // Re-encoded as a query string so PHP's own parser builds the `name[key][]` structure and mangles + // the names exactly as it does for `$_POST`. $pairs = []; foreach ($multipart->fields as $field) { - $pairs[] = \urlencode($field->name) . '=' . \urlencode($field->value); + $pairs[] = \rawurlencode($field->name) . '=' . \rawurlencode($field->value); } - $result = []; - \parse_str(\implode('&', $pairs), $result); - - return $result; + return self::parseQuery(\implode('&', $pairs)); } /** - * @return array{array, list} + * @param list $uploads Receives the path of every file taken over, also when this throws. * - * @psalm-suppress MixedReturnTypeCoercion + * @return array */ - private function createHttpFoundationFiles(Multipart $multipart): array + private static function createFiles(Multipart $multipart, array &$uploads): array { + // The trick of the fields, so file names nest and mangle the same way: PHP nests the indexes, and + // each index is then swapped for its file. + $pairs = []; $files = []; - /** @var list $paths */ - $paths = []; - - try { - foreach ($multipart->files as $file) { - if ($file->clientFilename === '') { - $uploaded = new UploadedFile('', '', null, \UPLOAD_ERR_NO_FILE, true); - } else { - $path = \tempnam(\sys_get_temp_dir(), 'rapira-upload-'); - if ($path === false) { - throw new \RuntimeException('Unable to create a temporary upload file.'); - } - $paths[] = $path; - - $source = @\fopen($file->tmpPath, 'rb'); - if ($source === false) { - throw new \RuntimeException(\sprintf('Unable to read Rapira upload "%s".', $file->tmpPath)); - } - $target = @\fopen($path, 'wb'); - if ($target === false) { - \fclose($source); - - throw new \RuntimeException(\sprintf('Unable to write temporary upload "%s".', $path)); - } - - try { - if (\stream_copy_to_stream($source, $target) === false) { - throw new \RuntimeException(\sprintf('Unable to copy Rapira upload "%s".', $file->tmpPath)); - } - } finally { - \fclose($source); - \fclose($target); - } - - $uploaded = new UploadedFile( - $path, - $file->clientFilename, - $file->clientMediaType, - \UPLOAD_ERR_OK, - true, - ); - } - - $this->addNested($files, $file->name, $uploaded); - } - } catch (\Throwable $exception) { - $this->cleanupUploadPaths($paths); - - throw $exception; + foreach ($multipart->files as $index => $file) { + $pairs[] = \rawurlencode($file->name) . '=' . $index; + $files[$index] = self::createFile($file, $uploads); } - return [$files, $paths]; - } + $tree = self::parseQuery(\implode('&', $pairs)); + \array_walk_recursive($tree, static function (mixed &$value) use ($files): void { + $value = $files[(int) $value]; + }); - /** - * @param list $paths - */ - private function cleanupUploadPaths(array $paths): void - { - foreach ($paths as $path) { - if (\is_file($path)) { - @\unlink($path); - } - } + return $tree; } /** - * @psalm-suppress MissingPureAnnotation, MixedAssignment - * - * @param array $target + * @param list $uploads */ - private function addNested(array &$target, string $name, mixed $value): void + private static function createFile(RapiraUploadedFile $file, array &$uploads): UploadedFile { - if (\preg_match('/^([^\[]+)((?:\[[^\]]*])*)$/', $name, $matches) !== 1) { - $target[$name] = $value; + if ($file->clientFilename === '') { + return new UploadedFile('', '', null, \UPLOAD_ERR_NO_FILE, true); + } - return; + // The host deletes its spool file once the exchange finalizes, which is before terminate(); a file + // renamed away survives that, and the bridge removes it after terminate() instead. + $path = @\tempnam(\dirname($file->tmpPath), 'rapira-upload-'); + if ($path === false) { + throw new \RuntimeException(\sprintf('Unable to create a file to keep Rapira upload "%s".', $file->tmpPath)); } + $uploads[] = $path; - $keys = [$matches[1]]; - if ($matches[2] !== '') { - \preg_match_all('/\[([^\]]*)]/', $matches[2], $bracketed); - foreach ($bracketed[1] as $key) { - $keys[] = $key; - } + if (!@\rename($file->tmpPath, $path)) { + throw new \RuntimeException(\sprintf('Unable to keep Rapira upload "%s".', $file->tmpPath)); } - $this->insert($target, $keys, $value); + return new UploadedFile($path, $file->clientFilename, $file->clientMediaType, \UPLOAD_ERR_OK, true); } /** - * @param array $target - * @param list $keys + * Parses a `Cookie` header the way PHP fills `$_COOKIE`: names and values URL-decoded, names mangled + * and nested by bracket syntax, and the first of two equal names wins. * - * @psalm-suppress MissingPureAnnotation, MixedAssignment, MixedArrayAssignment, MixedArgument + * @return array */ - private function insert(array &$target, array $keys, mixed $value): void + private static function parseCookies(string $header): array { - $key = \array_shift($keys); - if ($key === null) { - return; - } - if ($key === '') { - $target[] = $keys === [] ? $value : []; - if ($keys !== []) { - /** @var array-key $last */ - $last = \array_key_last($target); - $this->insert($target[$last], $keys, $value); + $pairs = []; + foreach (\explode(';', $header) as $pair) { + $parts = \explode('=', \trim($pair), 2); + if (\count($parts) !== 2 || $parts[0] === '' || isset($pairs[$parts[0]])) { + continue; } - - return; + // `&` is a legal cookie octet but a separator for `parse_str()`. + $pairs[$parts[0]] = \str_replace('&', '%26', $parts[0]) . '=' . \str_replace('&', '%26', $parts[1]); } - if ($keys === []) { - $target[$key] = $value; - return; - } - if (!isset($target[$key]) || !\is_array($target[$key])) { - $target[$key] = []; - } - $this->insert($target[$key], $keys, $value); + return self::parseQuery(\implode('&', $pairs)); } - private function normalizeRequest(RapiraRequest $request): RapiraRequest + /** + * @return array + */ + private static function parseQuery(string $query): array { - /** @var array> $headers */ - $headers = []; - /** @var array $names */ - $names = []; - foreach ($request->headers as $name => $values) { - $lower = \strtolower($name); - if (isset($names[$lower])) { - $headers[$names[$lower]] = [...$headers[$names[$lower]], ...$values]; - continue; - } - - $names[$lower] = $name; - $headers[$name] = $values; + if ($query === '') { + return []; } - return new RapiraRequest( - $request->method, - $request->uri, - $request->target, - $request->authority, - $request->protocol, - $headers, - $request->body, - $request->remote, - $request->server, - $request->tls, - $request->receivedAt, - ); + \parse_str($query, $result); + + return $result; } /** - * @psalm-pure + * @param array}> $headers * - * @param array> $headers + * @return array * - * @return array + * @psalm-mutation-free */ - private function parseCookies(array $headers): array + private function createServerParams(RapiraRequest $request, array $headers, string $query): array { - $cookies = []; - foreach ($headers as $name => $values) { - if (\strcasecmp($name, 'cookie') !== 0) { - continue; - } + $params = [ + 'SERVER_PROTOCOL' => $request->protocol, + 'REQUEST_METHOD' => $request->method, + 'REQUEST_URI' => $request->target, + 'QUERY_STRING' => $query, + 'REQUEST_TIME' => (int) $request->receivedAt, + 'REQUEST_TIME_FLOAT' => $request->receivedAt, + ]; + + if (\str_starts_with($request->uri, 'https:') || $request->tls !== null) { + $params['HTTPS'] = 'on'; + } + + if ($request->remote instanceof InetAddress) { + $params['REMOTE_ADDR'] = $request->remote->ip; + $params['REMOTE_PORT'] = $request->remote->port; + } elseif ($request->remote->path !== null) { + $params['REMOTE_ADDR'] = $request->remote->path; + } + + if ($request->server instanceof InetAddress) { + $params['SERVER_ADDR'] = $request->server->ip; + $params['SERVER_PORT'] = $request->server->port; + } - foreach ($values as $value) { - foreach (\explode(';', $value) as $pair) { - $parts = \explode('=', $pair, 2); - if (\count($parts) === 2) { - $cookies[\trim($parts[0])] = \trim($parts[1]); - } - } + $host = \parse_url($request->uri, \PHP_URL_HOST); + if (\is_string($host) && $host !== '') { + $params['SERVER_NAME'] = \trim($host, '[]'); + } + + // Named as the Rapira SAPI names them in Worker mode: `-` and `.` become `_`, and of two fields that + // map to one name the later wins. + foreach ($headers as $lower => [, $values]) { + $key = \strtoupper(\str_replace(['-', '.'], '_', $lower)); + $value = \implode($lower === 'cookie' ? '; ' : ', ', $values); + if ($key === 'CONTENT_TYPE' || $key === 'CONTENT_LENGTH') { + $params[$key] = $value; + } else { + $params['HTTP_' . $key] = $value; } } - return $cookies; + if ($request->authority !== null) { + $params['HTTP_HOST'] = $request->authority; + } + + return $params + $this->boot; } } diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 6262088..1eaec30 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -26,17 +26,16 @@ public function emit(Exchange $exchange, Request $request, Response $response): throw new \LogicException('Symfony EventStreamResponse is not supported in Rapira Dispatcher mode.'); } - $rawMethod = $request->attributes->getString('rapira.request_method'); - if ($rawMethod === '') { - $rawMethod = $request->server->getString('RAPIRA_REQUEST_METHOD', $request->server->getString('REQUEST_METHOD')); - } + // The method on the wire decides whether the host sends a body. Symfony's getMethod() uppercases it + // and honours method overrides, so it may say HEAD where the wire does not. + $rawMethod = $exchange->getRequest()->method; $prepareRequest = $request; if ($rawMethod === 'HEAD') { $prepareRequest = clone $request; $prepareRequest->setMethod('GET'); } elseif ($request->isMethod('HEAD')) { $prepareRequest = clone $request; - $prepareRequest->setMethod('RAPIRA_NONSTANDARD_HEAD'); + $prepareRequest->setMethod('GET'); } $response->prepare($prepareRequest); diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 691ee0d..7eaec7a 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -21,7 +21,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Testo\Assert; -use Testo\Skip; use Testo\Test; /** @@ -75,8 +74,7 @@ public function requestDataReachesTheApplicationWithoutTouchingTheGlobals(): voi [$request, $globals] = $this->serveWithGlobals($exchange); Assert::same($request->getMethod(), 'POST'); - Assert::same($request->server->get('RAPIRA_REQUEST_METHOD'), 'POST'); - Assert::same($request->attributes->get('rapira.request_method'), 'POST'); + Assert::same($request->server->get('REQUEST_METHOD'), 'POST'); Assert::same($request->server->get('REQUEST_URI'), '/path?q=one&q=two&raw=%2F'); Assert::same($request->server->get('SERVER_PROTOCOL'), 'HTTP/2'); Assert::same($request->server->get('REQUEST_TIME'), 1_700_000_000); @@ -154,7 +152,6 @@ public function urlEncodedFormIsParsedIntoNestedFields(): void ]); } - #[Skip('known bug: a form content type in another letter case is not parsed')] public function urlEncodedFormIsParsedWhateverTheContentTypeCase(): void { $request = $this->serveOne(FakeExchange::for( @@ -167,7 +164,7 @@ public function urlEncodedFormIsParsedWhateverTheContentTypeCase(): void Assert::same($request->request->all(), ['user' => ['name' => 'Ada']]); } - public function multipartFieldsAndFilesKeepTheirNestingAndUploadCopiesAreRemovedAfterwards(): void + public function multipartFieldsAndFilesKeepTheirNestingAndUploadsAreRemovedAfterwards(): void { $source = self::file('upload-body'); $seen = []; @@ -198,8 +195,7 @@ public function multipartFieldsAndFilesKeepTheirNestingAndUploadCopiesAreRemoved Assert::same($seen['content'], 'upload-body'); Assert::same($seen['empty'], \UPLOAD_ERR_NO_FILE); Assert::false(\is_file($seen['path'])); - Assert::true(\is_file($source)); - @\unlink($source); + Assert::false(\is_file($source)); } /** @@ -220,7 +216,7 @@ public function unreadableUploadLeavesNoTemporaryCopies(): void try { $this->serve($kernel, $exchange); } catch (\RuntimeException $exception) { - Assert::same($exception->getMessage(), \sprintf('Unable to read Rapira upload "%s".', $missing)); + Assert::same($exception->getMessage(), \sprintf('Unable to keep Rapira upload "%s".', $missing)); } finally { @\unlink($source); } @@ -249,7 +245,63 @@ public function uploadMovedByTheApplicationSurvivesTheCleanup(): void @\unlink($source); } - #[Skip('known bug: dispatcher cookies are not URL-decoded')] + /** + * The host deletes its spool file when the exchange finalizes, which happens before terminate(). + */ + public function uploadOutlivesTheHostSpoolFileUntilTerminate(): void + { + $source = self::file('late'); + $read = null; + $kernel = new TestKernel(terminate: static function (Request $request) use ($source, &$read): void { + @\unlink($source); + $read = \file_get_contents($request->files->get('file')->getPathname()); + }); + + $this->serve($kernel, FakeExchange::for('/upload', 'POST', body: new Multipart([], [ + new RapiraUploadedFile('file', 'file.txt', 'text/plain', [], $source, 4), + ]))); + + Assert::same($read, 'late'); + } + + public function multipartFileNamesAreMangledLikeFieldNames(): void + { + $source = self::file('body'); + $kernel = new TestKernel(); + + $this->serve($kernel, FakeExchange::for('/upload', 'POST', body: new Multipart( + [new FormField('field.name', 'value', []), new FormField('list one[]', 'item', [])], + [ + new RapiraUploadedFile('file.name', 'a.txt', 'text/plain', [], $source, 4), + new RapiraUploadedFile('list two[]', '', null, [], $source, 0), + ], + ))); + + $request = $kernel->requests[0]; + Assert::same($request->request->all(), ['field_name' => 'value', 'list_one' => ['item']]); + Assert::same(\array_keys($request->files->all()), ['file_name', 'list_two']); + Assert::true($request->files->all()['list_two'][0] instanceof UploadedFile); + } + + public function urlEncodedBodyIsParsedOnlyForTheMethodsThatTakeAForm(): void + { + $form = ['content-type' => ['application/x-www-form-urlencoded']]; + $kernel = new TestKernel(); + + $this->serve( + $kernel, + FakeExchange::for('/', 'PATCH', $form, 'a=1'), + FakeExchange::for('/', 'GET', $form, 'a=2'), + FakeExchange::for('/', 'patch', $form, 'a=3'), + ); + + [$patch, $get, $lowercase] = $kernel->requests; + Assert::same($patch->request->all(), ['a' => '1']); + Assert::same($get->request->all(), []); + Assert::same($get->getContent(), 'a=2'); + Assert::same($lowercase->request->all(), []); + } + public function cookieValuesAreUrlDecoded(): void { $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['sid=a%3Ab%3D; plain=a%20b']])); @@ -257,7 +309,6 @@ public function cookieValuesAreUrlDecoded(): void Assert::same($request->cookies->all(), ['sid' => 'a:b=', 'plain' => 'a b']); } - #[Skip('known bug: with duplicate dispatcher cookie names the last one wins instead of the first')] public function firstOfDuplicateCookieNamesWins(): void { $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['a=1; a=2', 'a=3']])); @@ -265,7 +316,6 @@ public function firstOfDuplicateCookieNamesWins(): void Assert::same($request->cookies->get('a'), '1'); } - #[Skip('known bug: several Cookie headers are joined with ", " instead of "; "')] public function severalCookieHeadersAreJoinedAsOneCookieList(): void { $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['a=1', 'b=2']])); @@ -275,7 +325,6 @@ public function severalCookieHeadersAreJoinedAsOneCookieList(): void Assert::same($request->server->get('HTTP_COOKIE'), 'a=1; b=2'); } - #[Skip('known bug: dispatcher cookies are not URL-decoded, so a cookie Symfony set does not round-trip')] public function cookieSetBySymfonyRoundTrips(): void { $value = 'a:b= c;d/é'; From 1c0eed4d066fdda05efa8edf94054bbed1e2c8e6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:44:43 +0400 Subject: [PATCH 25/42] fix: send dispatcher files through the host and stream in chunks A HEAD request for a BinaryFileResponse was prepared as a GET, so the whole file was read only for the host to drop it; the response is now prepared for the request as it is, which leaves the file unread and still lets deleteFileAfterSend() remove it. A file is handed to the host with Exchange::sendFile(), ranges as an offset and a length, so PHP no longer holds its bytes. A file deleted after sending, a temporary file, a stream wrapper path and a status that sends no file still go through PHP, as does a file the host refuses. Printed output was forwarded one byte at a time. It is now forwarded every 8 KiB and on every ob_flush(); a bare flush() reaches no output handler, so a body that relies on it alone waits for the chunk to fill, as under php-fpm with output_buffering. Output removed with ob_clean() is no longer forwarded, and the head now goes out with the first chunk instead of before the body runs. Header values of null are sent empty instead of reaching the host as null. The range test removed its file before reading back the slice handed to the host, so it could only pass while nothing was handed over. Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRunner.php | 4 +- src/Internal/ExchangeResponseEmitter.php | 246 ++++++++++++++++------- src/Internal/OutputBufferException.php | 13 ++ tests/Feature/DispatcherResponseTest.php | 118 ++++++++++- 4 files changed, 296 insertions(+), 85 deletions(-) create mode 100644 src/Internal/OutputBufferException.php diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index f8be441..b3bee4b 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -16,14 +16,12 @@ final readonly class DispatcherRunner implements RunnerInterface { private DispatcherRequestFactory $requestFactory; - private ExchangeResponseEmitter $responseEmitter; public function __construct( private HttpKernelInterface $kernel, private HttpDispatcher $dispatcher, ) { $this->requestFactory = new DispatcherRequestFactory(); - $this->responseEmitter = new ExchangeResponseEmitter(); } #[\Override] @@ -50,7 +48,7 @@ public function run(): int $response = $this->kernel->handle($request); try { - $this->responseEmitter->emit($exchange, $request, $response); + (new ExchangeResponseEmitter($exchange))->emit($request, $response); } catch (ResponseDiscardedException) { } diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 1eaec30..f6a6e9f 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -5,6 +5,7 @@ namespace Rapira\Symfony\Internal; use Rapira\Exception\WorkDiscardedException; +use Rapira\Http\Exception\FileNotSendableException; use Rapira\Http\Exchange; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\EventStreamResponse; @@ -12,169 +13,266 @@ use Symfony\Component\HttpFoundation\Response; /** + * Writes a Symfony response into one exchange: the Dispatcher-mode counterpart of {@see Response::send()}. + * + * A buffered body goes out with the head. A file of a {@see BinaryFileResponse} is handed to the host with + * {@see Exchange::sendFile()}, so PHP never holds its bytes. Anything else that prints its body is + * captured from the output layer and forwarded in chunks, the head going out with the first one, so a + * body that fails before printing anything can still be answered with an error. + * * @internal */ -final readonly class ExchangeResponseEmitter +final class ExchangeResponseEmitter { - public function emit(Exchange $exchange, Request $request, Response $response): void + /** + * Bytes of printed output held before they are forwarded. An explicit `ob_flush()` forwards at once; a + * bare `flush()` reaches no output handler, so it cannot. + */ + private const CHUNK_SIZE = 8192; + + private bool $headWritten = false; + + /** + * @psalm-mutation-free + */ + public function __construct( + private readonly Exchange $exchange, + ) {} + + /** + * @throws ResponseDiscardedException The host closed the exchange: the client left or the deadline passed. + * @throws OutputBufferException The body left the output buffer stack in a state that cannot be restored. + */ + public function emit(Request $request, Response $response): void { - if ($response->getStatusCode() >= 100 && $response->getStatusCode() < 200 && $response->getStatusCode() !== 101) { - throw new \LogicException('A terminal Symfony response cannot use an interim HTTP status.'); + $this->send($request, $response); + } + + /** + * The state prepare() leaves in a BinaryFileResponse, which it keeps to itself. + */ + private static function fileState(BinaryFileResponse $response, string $property): mixed + { + return (new \ReflectionProperty(BinaryFileResponse::class, $property))->getValue($response); + } + + /** + * A broken buffer stack outweighs any failure of the response itself: the process cannot serve on. + * + * @psalm-pure + */ + private static function firstFailure(?\Throwable $first, \Throwable $next): \Throwable + { + return $next instanceof OutputBufferException && !$first instanceof OutputBufferException + ? $next + : $first ?? $next; + } + + /** + * @return array> + */ + private static function headers(Response $response): array + { + $headers = []; + /** + * @var non-empty-string $name + * @var list $values + */ + foreach ($response->headers->allPreserveCaseWithoutCookies() as $name => $values) { + foreach ($values as $value) { + $headers[$name][] = (string) $value; + } } - if (\class_exists(EventStreamResponse::class) && $response instanceof EventStreamResponse) { + foreach ($response->headers->getCookies() as $cookie) { + $headers['Set-Cookie'][] = (string) $cookie; + } + + return $headers; + } + + private function send(Request $request, Response $response): void + { + $status = $response->getStatusCode(); + if ($status < 200 && $status !== 101) { + throw new \LogicException('A terminal Symfony response cannot use an interim HTTP status.'); + } + // It closes every output buffer it can after each event, so none is left to capture the next one. + if ($response instanceof EventStreamResponse) { throw new \LogicException('Symfony EventStreamResponse is not supported in Rapira Dispatcher mode.'); } - // The method on the wire decides whether the host sends a body. Symfony's getMethod() uppercases it - // and honours method overrides, so it may say HEAD where the wire does not. - $rawMethod = $exchange->getRequest()->method; - $prepareRequest = $request; - if ($rawMethod === 'HEAD') { - $prepareRequest = clone $request; - $prepareRequest->setMethod('GET'); - } elseif ($request->isMethod('HEAD')) { - $prepareRequest = clone $request; - $prepareRequest->setMethod('GET'); + // Whether the host sends a body depends on the method on the wire. Symfony's getMethod() uppercases + // it and honours overrides, so it may call HEAD what the wire does not. + $head = $this->exchange->getRequest()->method === 'HEAD'; + if (!$head && $request->isMethod('HEAD')) { + $request = clone $request; + $request->setMethod('GET'); } - $response->prepare($prepareRequest); + $response->prepare($request); + // prepare() sets the length of the file before it knows the status that sends none of it. if ($response instanceof BinaryFileResponse && !$response->isSuccessful()) { $response->headers->remove('Content-Length'); } /** @var int<100, 599> $status */ $status = $response->getStatusCode(); - $headers = $this->headers($response); + $headers = self::headers($response); - if ($rawMethod === 'HEAD' || $response->isEmpty()) { - $this->writeHead($exchange, $status, $headers); - if ($response instanceof BinaryFileResponse) { - $this->sendContent($exchange, $response, false); - } + if ($head || $response->isEmpty()) { + $this->writeHead($status, $headers); + // Sends nothing after prepare(), but deletes the file of deleteFileAfterSend(). + $response instanceof BinaryFileResponse and $this->sendContent($response, null); + $this->writeBody(''); - $this->writeBody($exchange, ''); + return; + } + if ($response instanceof BinaryFileResponse && $this->sendFile($response, $status, $headers)) { return; } $content = $response->getContent(); if ($content !== false) { - $this->writeHead($exchange, $status, $headers); - $this->writeBody($exchange, $content); + $this->writeHead($status, $headers); + $this->writeBody($content); return; } - $this->writeHead($exchange, $status, $headers); - $this->sendContent($exchange, $response, true); - $this->writeBody($exchange, ''); + $this->sendContent($response, function (string $chunk) use ($status, $headers): void { + $this->headWritten or $this->writeHead($status, $headers); + $this->writeBody($chunk, false); + }); + $this->headWritten or $this->writeHead($status, $headers); + $this->writeBody(''); } /** - * @return array> + * Hands the file to the host. False when PHP has to send it instead, with the head possibly written. + * + * @param int<100, 599> $status + * @param array> $headers */ - private function headers(Response $response): array + private function sendFile(BinaryFileResponse $response, int $status, array $headers): bool { - /** @var array> $headers */ - $headers = $response->headers->allPreserveCaseWithoutCookies(); - $cookies = $response->headers->getCookies(); - if ($cookies !== []) { - $headers['Set-Cookie'] = \array_values(\array_map( - static fn(object $cookie): string => (string) $cookie, - $cookies, - )); + /** @var int $offset */ + $offset = self::fileState($response, 'offset'); + /** @var int $maxlen */ + $maxlen = self::fileState($response, 'maxlen'); + /** @var bool $deleteAfterSend */ + $deleteAfterSend = self::fileState($response, 'deleteFileAfterSend'); + /** @var \SplTempFileObject|null $tempFile */ + $tempFile = self::fileState($response, 'tempFileObject'); + + // The host reads the file after this worker has moved on, so one deleted after sending has to be + // sent by PHP. So does a temporary file or a stream, which have no path the host can open, and a + // status that sends no file. + $path = $response->getFile()->getRealPath(); + if (!$response->isSuccessful() || $deleteAfterSend || $tempFile !== null || $path === false || $maxlen === 0) { + return false; } - return $headers; + $this->writeHead($status, $headers); + try { + $this->exchange->sendFile($path, \max(0, $offset), $maxlen > 0 ? $maxlen : null); + } catch (FileNotSendableException) { + // Outside the host's sendfile root, say. Raised before anything is written. + return false; + } catch (WorkDiscardedException $exception) { + throw new ResponseDiscardedException($exception); + } + + return true; } - private function sendContent(Exchange $exchange, Response $response, bool $forward): void + /** + * Runs {@see Response::sendContent()} inside an output buffer of its own and passes what it prints + * to $forward. + * + * A throw out of an output handler does not reach the code that printed, so the first failure is + * kept, the rest of the output is dropped, and the failure is raised once the body returns. The body + * is not told: `connection_aborted()` reports the SAPI's connection, not the exchange. + * + * @param null|\Closure(string): void $forward Null drops the output. + */ + private function sendContent(Response $response, ?\Closure $forward): void { $baseLevel = \ob_get_level(); + /** @var \Throwable|null $failure */ $failure = null; - \ob_start(function (string $chunk) use ($exchange, $forward, &$failure): string { - if ($failure !== null || !$forward || $chunk === '') { + \ob_start(static function (string $chunk, int $phase) use ($forward, &$failure): string { + if ($forward === null || $failure !== null || $chunk === '' || ($phase & \PHP_OUTPUT_HANDLER_CLEAN) !== 0) { return ''; } try { - $this->writeBody($exchange, $chunk, false); + $forward($chunk); } catch (\Throwable $exception) { $failure = $exception; } return ''; - }, 1); + }, self::CHUNK_SIZE); $ownedLevel = $baseLevel + 1; try { $response->sendContent(); - $this->closeBuffersAbove($ownedLevel, true); - if (\ob_get_level() !== $ownedLevel) { - throw new \LogicException('The response removed the Dispatcher output buffer.'); - } - if (!@\ob_end_flush()) { - throw new \LogicException('The Dispatcher output buffer could not be flushed.'); - } + $this->closeBuffersAbove($ownedLevel); + \ob_get_level() === $ownedLevel + or throw new OutputBufferException('The response removed the Dispatcher output buffer.'); + @\ob_end_flush() or throw new OutputBufferException('The Dispatcher output buffer could not be flushed.'); } catch (\Throwable $exception) { - $failure ??= $exception; + $failure = self::firstFailure($failure, $exception); } finally { try { $this->restoreBufferLevel($baseLevel); - } catch (\Throwable $exception) { - $failure ??= $exception; + } catch (OutputBufferException $exception) { + $failure = self::firstFailure($failure, $exception); } } - if ($failure !== null) { - throw $failure; - } + $failure === null or throw $failure; } - private function closeBuffersAbove(int $targetLevel, bool $flush): void + private function closeBuffersAbove(int $targetLevel): void { while (\ob_get_level() > $targetLevel) { - $level = \ob_get_level(); - $closed = $flush ? @\ob_end_flush() : @\ob_end_clean(); - if (!$closed || \ob_get_level() >= $level) { - throw new \LogicException('A nested output buffer cannot be removed safely.'); - } + @\ob_end_flush() or throw new OutputBufferException('A nested output buffer cannot be removed safely.'); } } private function restoreBufferLevel(int $targetLevel): void { while (\ob_get_level() > $targetLevel) { - $level = \ob_get_level(); - if (!@\ob_end_clean() || \ob_get_level() >= $level) { - throw new \LogicException('The output buffer stack cannot be restored safely.'); - } + @\ob_end_clean() or throw new OutputBufferException('The output buffer stack cannot be restored safely.'); } - if (\ob_get_level() < $targetLevel) { - throw new \LogicException('The response removed an output buffer it does not own.'); - } + \ob_get_level() === $targetLevel + or throw new OutputBufferException('The response removed an output buffer it does not own.'); } /** * @param int<100, 599> $status * @param array> $headers */ - private function writeHead(Exchange $exchange, int $status, array $headers): void + private function writeHead(int $status, array $headers): void { try { - $exchange->writeHead($status, $headers); + $this->exchange->writeHead($status, $headers); } catch (WorkDiscardedException $exception) { throw new ResponseDiscardedException($exception); } + + $this->headWritten = true; } - private function writeBody(Exchange $exchange, string $content, bool $eos = true): void + private function writeBody(string $content, bool $eos = true): void { try { - $exchange->writeBody($content, $eos); + $this->exchange->writeBody($content, $eos); } catch (WorkDiscardedException $exception) { throw new ResponseDiscardedException($exception); } diff --git a/src/Internal/OutputBufferException.php b/src/Internal/OutputBufferException.php new file mode 100644 index 0000000..84cbfae --- /dev/null +++ b/src/Internal/OutputBufferException.php @@ -0,0 +1,13 @@ + ['bytes=2-5']]); $unsatisfiable = FakeExchange::for('/file', headers: ['range' => ['bytes=999-1000']]); + // A file handed to the host is read after the response is emitted, so it has to outlive the checks. try { $this->serve($respond, $range, $unsatisfiable); + + Assert::same($range->status, 206); + Assert::same($range->headers['Content-Range'], ['bytes 2-5/10']); + Assert::same(self::deliveredBody($range), '2345'); + Assert::true($range->isFinalized()); + Assert::same($unsatisfiable->status, 416); + Assert::null($unsatisfiable->header('content-length')); + Assert::same(self::deliveredBody($unsatisfiable), ''); + Assert::true($unsatisfiable->isFinalized()); + } finally { + @\unlink($path); + } + } + + public function fileAndFileRangeAreHandedToTheHost(): void + { + $path = self::file('0123456789'); + $respond = static fn(): Response => new BinaryFileResponse($path, headers: ['Content-Type' => 'text/plain']); + $whole = FakeExchange::for('/file'); + $range = FakeExchange::for('/file', headers: ['range' => ['bytes=2-5']]); + + try { + $this->serve($respond, $whole, $range); } finally { @\unlink($path); } + $realPath = (string) \realpath(\dirname($path)) . \DIRECTORY_SEPARATOR . \basename($path); + Assert::same($whole->status, 200); + Assert::same($whole->headers['Content-Length'], ['10']); + Assert::same($whole->sentFiles, [['path' => $realPath, 'offset' => 0, 'length' => null]]); + Assert::same($whole->chunks, []); + Assert::true($whole->isFinalized()); Assert::same($range->status, 206); - Assert::same($range->headers['Content-Range'], ['bytes 2-5/10']); - Assert::same(self::deliveredBody($range), '2345'); + Assert::same($range->sentFiles, [['path' => $realPath, 'offset' => 2, 'length' => 4]]); Assert::true($range->isFinalized()); - Assert::same($unsatisfiable->status, 416); - Assert::null($unsatisfiable->header('content-length')); - Assert::same(self::deliveredBody($unsatisfiable), ''); - Assert::true($unsatisfiable->isFinalized()); + } + + public function fileTheHostRefusesIsStreamedUnderTheSameHead(): void + { + $path = self::file('0123456789'); + $exchange = FakeExchange::for('/file', headers: ['range' => ['bytes=2-5']]); + $exchange->refuseFiles = true; + + try { + $this->serve(static fn(): Response => new BinaryFileResponse($path, headers: ['Content-Type' => 'text/plain']), $exchange); + } finally { + @\unlink($path); + } + + Assert::same($exchange->status, 206); + Assert::same($exchange->headers['Content-Length'], ['4']); + Assert::same($exchange->sentFiles, []); + Assert::same($exchange->getBody(), '2345'); + Assert::true($exchange->isFinalized()); + } + + public function fileDeletedAfterSendIsStreamedAndThenDeleted(): void + { + $path = self::file('0123456789'); + $exchange = FakeExchange::for('/file'); + + $this->serve(static fn(): Response => (new BinaryFileResponse( + $path, + headers: ['Content-Type' => 'text/plain'], + ))->deleteFileAfterSend(), $exchange); + + Assert::same($exchange->sentFiles, []); + Assert::same($exchange->getBody(), '0123456789'); + Assert::true($exchange->isFinalized()); + Assert::false(\is_file($path)); + } + + public function streamedOutputIsForwardedInChunksAndOnEveryObFlush(): void + { + $exchange = FakeExchange::for('/'); + + $this->serve(static fn(): Response => new StreamedResponse(static function (): void { + for ($i = 0; $i < 100; ++$i) { + echo 'a'; + } + \ob_flush(); + echo \str_repeat('b', 10_000); + echo 'c'; + }), $exchange); + + $chunks = \array_values(\array_filter($exchange->chunks, static fn(string $chunk): bool => $chunk !== '')); + Assert::same($chunks[0], \str_repeat('a', 100)); + Assert::same(\implode('', \array_slice($chunks, 1)), \str_repeat('b', 10_000) . 'c'); + Assert::true(\count($chunks) > 2); + Assert::true($exchange->isFinalized()); + } + + public function cleanedOutputIsNotForwarded(): void + { + $exchange = FakeExchange::for('/'); + + $this->serve(static fn(): Response => new StreamedResponse(static function (): void { + echo 'discarded'; + \ob_clean(); + echo 'kept'; + }), $exchange); + + Assert::same($exchange->getBody(), 'kept'); + } + + public function headerWithoutAValueIsSentEmpty(): void + { + $exchange = FakeExchange::for('/'); + $response = new Response('body'); + $response->headers->set('X-Empty', null); + + $this->serve(static fn(): Response => $response, $exchange); + + Assert::same($exchange->headers['X-Empty'], ['']); } /** From 08d3747cdd800c10d8002a900a7f54a7c382bae7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:46:19 +0400 Subject: [PATCH 26/42] fix: answer dispatcher failures before the head and keep serving A request that could not be converted, a terminal 1xx status, an EventStreamResponse, a header value the host rejects or a body that failed before printing anything propagated out of the loop, so one bad request cost the worker and every exchange queued behind it. Until a response head is written another response can still take its place, so such a failure is now logged through Rapira\log() and answered with a plain-text error, 400 when Symfony classifies the conversion failure as a bad request and 500 otherwise, and the loop goes on; a handled response that failed still terminates. A failure of the kernel, of terminate(), of a response whose head is already out, or of the output buffer stack still propagates, since the worker cannot vouch for its state after them. Each exchange is served in a method of its own, so neither it nor the request outlives the call into the next receive(). Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRunner.php | 76 ++++++++++++++++-------- src/Internal/ExchangeResponseEmitter.php | 32 +++++++++- src/Internal/UnsentResponseException.php | 21 +++++++ tests/Feature/DispatcherModeTest.php | 49 ++++++++++++--- 4 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 src/Internal/UnsentResponseException.php diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index b3bee4b..fd4ecae 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -5,12 +5,22 @@ namespace Rapira\Symfony\Internal; use Rapira\Exception\ClosedException; +use Rapira\Http\Exchange; use Rapira\Http\HttpDispatcher; +use Rapira\LogLevel; +use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; /** + * Serves Rapira's Dispatcher mode: takes exchanges from the HTTP dispatcher one at a time and runs each + * through the resident kernel. + * + * A request that cannot be converted, or a response that fails before its head is written, is answered + * with an error and logged, and the loop goes on. A failure of the kernel, of terminate(), or of a + * response whose head is out propagates, and the host replaces the worker. + * * @internal */ final readonly class DispatcherRunner implements RunnerInterface @@ -34,35 +44,53 @@ public function run(): int return 0; } - $converted = null; - $request = null; - $response = null; + $this->serve($exchange); + // Held across receive(), an exchange left unfinalized would stay open until the next one arrives. + $exchange = null; + \gc_collect_cycles(); + } + } - try { - if ($exchange->isCancelled()) { - continue; - } + private static function report(string $message, \Throwable $exception): void + { + \Rapira\log($message, LogLevel::Error, ['exception' => $exception]); + } + + private function serve(Exchange $exchange): void + { + // The client left or the deadline passed while the exchange waited in the queue. + if ($exchange->isCancelled()) { + return; + } + + $emitter = new ExchangeResponseEmitter($exchange); - $converted = $this->requestFactory->create($exchange->getRequest()); - $request = $converted->request; - $response = $this->kernel->handle($request); + try { + $converted = $this->requestFactory->create($exchange->getRequest()); + } catch (\Throwable $exception) { + self::report('The Rapira request could not be converted to a Symfony request.', $exception); + $emitter->emitError($exception instanceof RequestExceptionInterface ? 400 : 500); + + return; + } - try { - (new ExchangeResponseEmitter($exchange))->emit($request, $response); - } catch (ResponseDiscardedException) { - } + try { + $request = $converted->request; + $response = $this->kernel->handle($request); + + try { + $emitter->emit($request, $response); + } catch (ResponseDiscardedException) { + } catch (UnsentResponseException $exception) { + self::report('The Symfony response could not be sent.', $exception->getPrevious() ?? $exception); + $emitter->emitError(500); + } - if ($this->kernel instanceof TerminableInterface) { - $this->kernel->terminate($request, $response); - } - } finally { - $response = null; - $request = null; - $converted?->cleanup(); - $converted = null; - $exchange = null; - \gc_collect_cycles(); + if ($this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($request, $response); } + } finally { + $converted->cleanup(); } } } diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index f6a6e9f..9fe8f8e 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -6,6 +6,7 @@ use Rapira\Exception\WorkDiscardedException; use Rapira\Http\Exception\FileNotSendableException; +use Rapira\Http\Exception\HeadAlreadyWrittenError; use Rapira\Http\Exchange; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\EventStreamResponse; @@ -41,11 +42,40 @@ public function __construct( /** * @throws ResponseDiscardedException The host closed the exchange: the client left or the deadline passed. + * @throws UnsentResponseException The response failed before its head was written. * @throws OutputBufferException The body left the output buffer stack in a state that cannot be restored. */ public function emit(Request $request, Response $response): void { - $this->send($request, $response); + try { + $this->send($request, $response); + } catch (ResponseDiscardedException|OutputBufferException $exception) { + throw $exception; + } catch (\Throwable $exception) { + $this->headWritten or throw new UnsentResponseException($exception); + + throw $exception; + } + } + + /** + * Answers with a plain-text error, unless a head is already written: the host then fails the + * exchange once it is released unfinalized. + * + * @param int<400, 599> $status + */ + public function emitError(int $status): void + { + if ($this->headWritten || $this->exchange->isFinalized()) { + return; + } + + $this->headWritten = true; + try { + $this->exchange->writeHead($status, ['Content-Type' => ['text/plain; charset=UTF-8']]); + $this->exchange->writeBody(Response::$statusTexts[$status] ?? 'Error'); + } catch (WorkDiscardedException|HeadAlreadyWrittenError) { + } } /** diff --git a/src/Internal/UnsentResponseException.php b/src/Internal/UnsentResponseException.php new file mode 100644 index 0000000..ff92a89 --- /dev/null +++ b/src/Internal/UnsentResponseException.php @@ -0,0 +1,21 @@ +exchange->getBody())->notContains('event'); } - #[Skip('known bug: a request that fails to convert kills the loop instead of answering 500')] public function requestConversionFailureIsAnsweredWithAnErrorAndTheLoopGoesOn(): void { $missing = \sys_get_temp_dir() . '/rapira-missing-' . \bin2hex(\random_bytes(8)); @@ -185,7 +185,6 @@ public function requestConversionFailureIsAnsweredWithAnErrorAndTheLoopGoesOn(): $this->assertAnsweredWith500AndTheNextServed($failing, $failing, new TestKernel()); } - #[Skip('known bug: an EventStreamResponse kills the loop instead of answering 500')] public function eventStreamResponseIsAnsweredWithAnErrorAndTheLoopGoesOn(): void { $failing = FakeExchange::for('/events'); @@ -196,7 +195,6 @@ public function eventStreamResponseIsAnsweredWithAnErrorAndTheLoopGoesOn(): void $this->assertAnsweredWith500AndTheNextServed($failing, $failing, $kernel); } - #[Skip('known bug: a terminal 1xx response kills the loop instead of answering 500')] public function terminalInterimStatusIsAnsweredWithAnErrorAndTheLoopGoesOn(): void { $failing = FakeExchange::for('/early'); @@ -207,7 +205,6 @@ public function terminalInterimStatusIsAnsweredWithAnErrorAndTheLoopGoesOn(): vo $this->assertAnsweredWith500AndTheNextServed($failing, $failing, $kernel); } - #[Skip('known bug: a header value the host rejects kills the loop instead of answering 500')] public function headerValueTheHostRejectsIsAnsweredWithAnErrorAndTheLoopGoesOn(): void { $failing = new ScriptedExchange(FakeExchange::for('/bad-header')); @@ -218,6 +215,37 @@ public function headerValueTheHostRejectsIsAnsweredWithAnErrorAndTheLoopGoesOn() $this->assertAnsweredWith500AndTheNextServed($failing, $failing->exchange, $kernel); } + public function streamFailingBeforeItPrintsIsAnsweredWithAnErrorAndStillTerminated(): void + { + $failing = FakeExchange::for('/stream'); + $kernel = new TestKernel(static fn(Request $request): Response => $request->getPathInfo() === '/stream' + ? new StreamedResponse(static fn() => throw new \RuntimeException('stream failed')) + : new Response($request->getPathInfo())); + + $this->assertAnsweredWith500AndTheNextServed($failing, $failing, $kernel); + + Assert::same($kernel->events, ['handle:/stream', 'terminate:/stream', 'handle:/next', 'terminate:/next']); + } + + public function streamFailingAfterItsHeadIsOutPropagates(): never + { + $failing = FakeExchange::for('/stream'); + $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function (): void { + echo 'partial'; + \ob_flush(); + + throw new \RuntimeException('stream failed'); + })); + + Expect::exception(\RuntimeException::class)->withMessage('stream failed'); + try { + $this->serve($kernel, new FakeHttpDispatcher($failing, FakeExchange::for('/must-not-run'))); + } finally { + Assert::same($failing->getBody(), 'partial'); + Assert::false($failing->isFinalized()); + } + } + /** * @param FakeExchange $answer What $failing records the answer in. */ @@ -225,17 +253,22 @@ private function assertAnsweredWith500AndTheNextServed(Exchange $failing, FakeEx { $next = FakeExchange::for('/next'); - $this->serve($kernel, new FakeHttpDispatcher($failing, $next)); + $host = $this->serve($kernel, new FakeHttpDispatcher($failing, $next)); Assert::same($answer->status, 500); Assert::true($answer->isFinalized()); Assert::same($next->getBody(), '/next'); + Assert::count($host->logs, 1); + Assert::same($host->logs[0]['level'], LogLevel::Error); + Assert::instanceOf($host->logs[0]['context']['exception'], \Throwable::class); } - private function serve(TestKernel $kernel, FakeHttpDispatcher $dispatcher): void + private function serve(TestKernel $kernel, FakeHttpDispatcher $dispatcher): FakeRuntime { - (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); + $host = (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); Assert::same(self::runtime()->getRunner($kernel)->run(), 0); + + return $host; } } From 75e68ad0df2e76e7534e2d31b215e7177f29fa5a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 00:48:11 +0400 Subject: [PATCH 27/42] docs: describe the lifecycles, streaming and file sending as they now work The README described Classic mode as Symfony's runner, dispatcher uploads as copies, streaming without a chunk size and sendFile() as unused, and told readers to install a branch alias that is no longer needed. It also ruled out native sessions in Dispatcher mode, while Symfony's session listener takes the session id from the request's cookies and sets the cookie on the response, so NativeSessionStorage works there; only a direct session_start() does not. Symfony's Kernel::boot() resolves the REMOTE_ADDR placeholder of trusted_proxies once, from the $_SERVER of the moment, which in a resident worker is the first request's client; the README now warns against it. Assisted-By: Claude Opus 5.5 --- README.md | 63 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 003b603..72d5b15 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,18 @@ # rapira/symfony -Symfony Runtime integration for [Rapira](https://rapira.rs/) Worker and Dispatcher modes. It boots one Symfony kernel and uses it for sequential requests handled by a resident Rapira process. Classic mode delegates to Symfony. The standard Symfony `public/index.php` stays unchanged in every mode. +Symfony Runtime for [Rapira](https://rapira.rs/). The same `public/index.php` serves a Symfony HTTP kernel in every Rapira mode: Classic boots it per request, Worker and Dispatcher keep one kernel alive and serve requests one after another. ## Installation -After the first release is published on Packagist, install the package normally: - ```shell composer require rapira/symfony ``` -Until then, applications testing this branch can require `rapira/symfony:dev-feat/symfony-runtime`. - -The package requires PHP 8.4 or newer and Symfony 7.4 or 8.0. The Rapira server must be version 0.9.0 or newer; Composer installs the PHP contracts but cannot enforce the version of the Rapira binary. +Requires PHP 8.4 or newer, the Symfony components `http-foundation`, `http-kernel` and `runtime` in versions `^7.4 || ^8.0`, and Rapira 0.9.0 or newer. Composer installs the PHP contract but cannot check the version of the Rapira binary. ## Application setup -Select the Runtime in the root application's `composer.json`: +Select the Runtime in the application's `composer.json` and run `composer dump-autoload`: ```json { @@ -28,7 +24,7 @@ Select the Runtime in the root application's `composer.json`: } ``` -Run `composer dump-autoload` after changing the Runtime configuration. Symfony generates its stock `vendor/autoload_runtime.php`; no custom autoload template or separate worker entrypoint is required. Keep the standard generated `public/index.php` unchanged: +Keep the stock `public/index.php`: ```php terminate()`. +- **Worker** loops over `Rapira\handle_request()`. Per request: the request is built from the superglobals, the response is sent and finished, then terminate runs and cyclic garbage is collected. +- **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time; one cancelled while it was queued is skipped. + +Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse anything else with a `LogicException`. Rapira owns recycling and `max_requests`; the Runtime adds no request limit of its own. + +In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_*` or `HTTPS`. + +## Dispatcher requests + +The superglobals are neither read nor filled. The Symfony `Request` holds what PHP would have put there: the exact request target in `REQUEST_URI`, the method byte for byte in `REQUEST_METHOD`, every header value, query and cookie values decoded and nested by PHP's rules with the first of two equal cookie names winning, url-encoded bodies parsed for `POST`, `PUT`, `PATCH` and `DELETE`, multipart fields and files with PHP's name mangling, TLS state and both network addresses. The boot-time variables are added as in Worker mode. -Rapira owns recycling and `max_requests`; this runtime does not apply a second request limit. Dispatcher drain through `ClosedException` exits cleanly. Work already cancelled before conversion is skipped. If cancellation happens while emitting a handled response, transport output stops but `kernel->terminate()` still runs; cancellation exceptions thrown by application or termination code are not swallowed. Unexpected application, adapter, termination, and transport programming errors propagate so Rapira can recycle the worker. +Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://input`, or answers through `header()` and `echo` outside a response body, does not see the request. Symfony sessions work, `NativeSessionStorage` included: the session listener takes the session id from the request's cookies and sets the session cookie on the response. Calling `session_start()` directly does not, since PHP looks for the id in `$_COOKIE` and sends its cookie through `header()`. -## Dispatcher semantics +Uploaded files are renamed out of Rapira's spool, which the host empties once the response is sent, so they stay readable through `kernel->terminate()`; the Runtime removes them afterwards. A file the application moved stays where it was moved. -Dispatcher mode does not synthesize request superglobals. `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`, `php://input`, `header()`, native PHP sessions, and libraries that require SAPI request globals should not be used for request handling. Use the Symfony `Request`, response headers, and a Symfony-compatible session implementation. +## Dispatcher responses -The conversion preserves the exact request target in `REQUEST_URI`, byte-exact raw method for transport decisions, protocol, repeated headers, cookies, body, parsed forms, nested multipart fields and uploads, TLS state, and network addresses. Live request metadata always wins over the non-HTTP boot-time server defaults, and boot-time `HTTP_*` values are excluded. +Responses are prepared by HttpFoundation, with every repeated header and `Set-Cookie` value kept. -Multipart uploads are copied to Symfony-owned temporary files. They remain readable through `kernel->terminate()` and are removed after termination. Files moved by application code remain at their destination. +- A buffered body goes out with the head in one write. +- A `BinaryFileResponse` is handed to Rapira with `Exchange::sendFile()`, ranges included, so PHP never holds the bytes. A temporary file, a file deleted after sending and a file Rapira refuses are streamed by PHP instead. +- A `StreamedResponse`, or anything else that prints its body, is captured and forwarded every 8 KiB and on every `ob_flush()`. A bare `flush()` reaches no output handler, so call `ob_flush()` before it where latency matters. The head goes out with the first chunk. +- `HEAD`, `204` and `304` send no body. A `HEAD` request for a file does not read it, and `deleteFileAfterSend()` still applies. +- If the client leaves mid-stream, the rest of the output is dropped and terminate still runs. The streaming code is not told: `connection_aborted()` does not reflect the exchange. +- `EventStreamResponse` is answered with `500`: it closes every output buffer after each event, so nothing is left to capture the next one. Use a `StreamedResponse` that calls `ob_flush()` after each event. -Responses are prepared by HttpFoundation before direct emission. Repeated headers and every `Set-Cookie` value are preserved. Ordinary responses finalize exactly once; `StreamedResponse`, streamed JSON, and binary responses progressively forward output to Rapira and then send end-of-stream. HEAD retains prepared metadata internally (including binary file length) while Rapira applies its own wire-level HEAD framing; 204, 304, file ranges, 416 responses, temporary files, and `deleteFileAfterSend()` follow Symfony's public behavior. Terminal 100–199 responses other than 101 are rejected because Rapira treats them as interim. Symfony's built-in `EventStreamResponse` is currently rejected because it removes output buffers it does not own; use `StreamedResponse` for server-sent events. Dispatcher mode intentionally does not use `Response::send()`, fibers, or Rapira's zero-copy `sendFile()` yet. +A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on. A failure of the kernel, of `kernel->terminate()`, or of a response whose head is already out propagates, and Rapira replaces the worker. -## Persistent state and resets +## Persistent state -Worker and Dispatcher modes keep the PHP process, kernel, container, and loaded services alive between requests. Avoid request-specific state in static properties, singletons, or long-lived services. Symfony's next-handle reset lifecycle still applies to services tagged for reset; other state must be reset by the application. +In Worker and Dispatcher modes the process, kernel, container and services outlive each request. Keep request state out of static properties and long-lived services; Symfony resets services tagged `kernel.reset` between requests, anything else is the application's to reset. -For each successful Dispatcher request the order is handle, response finalization, `kernel->terminate()`, upload cleanup, then cyclic garbage collection. Worker mode finalizes through the SAPI callback before termination. Both modes accept the next request only after the previous lifecycle completes. +The kernel boots once, on the first request, and `Kernel::boot()` resolves the `REMOTE_ADDR` placeholder of `trusted_proxies` from `$_SERVER` at that moment. In Worker mode that makes the first client's address a trusted proxy for the life of the worker; in Dispatcher mode it resolves against the boot-time `$_SERVER`, which normally holds no client address. List the proxy addresses or subnets explicitly, or use `PRIVATE_SUBNETS`, instead of `REMOTE_ADDR`. From d54f4a66b58f6b55141678cf945a324e6fa50151 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:24:08 +0400 Subject: [PATCH 28/42] fix: match the dispatcher request to the Rapira SAPI and PHP parsing The SDK's dispatcher request factory was checked against php -S and the Rapira SAPI source, and the bridge differed from it in several places. A header field named outside [A-Za-z0-9-] reached the server bag, with `.` turned into `_`, and the header bag, which reads `_` as `-`; the host drops such a field in Worker mode, and here X_Forwarded_For could pose as X-Forwarded-For. It is now dropped as well. Cookie values were URL-decoded with `+` as a space, a pair without `=` was skipped and a duplicate was detected before name mangling; PHP decodes cookies raw, keeps a bare name with an empty value and keeps the first of two names that mangle to one key. A form media type was cut only at a space or `;`, not at `,`. Form bodies were parsed only for POST, PUT, PATCH and DELETE, while the contract reads a body by its framing and never by the method name; a url-encoded or multipart body is now parsed by its Content-Type alone, for QUERY, GET or any other method. PHP drops an upload whose name has broken brackets, `u[v` or `a[b]c` say, while it repairs a text field of the same name; the bridge nested it. The server params now carry what the SAPI sets in Worker mode and were missing here: REQUEST_SCHEME, GATEWAY_INTERFACE, SERVER_SOFTWARE, DOCUMENT_URI, and HTTP_CONTENT_TYPE and HTTP_CONTENT_LENGTH next to the CONTENT_* pair. The test that pinned REQUEST_SCHEME as absent was checking that the boot-time value does not leak, so it now expects the request's own scheme. Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRequestFactory.php | 98 ++++++++++++---- tests/Feature/DispatcherRequestTest.php | 137 ++++++++++++++++++++-- 2 files changed, 204 insertions(+), 31 deletions(-) diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index fd82c25..e24dc58 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -22,9 +22,6 @@ */ final readonly class DispatcherRequestFactory { - /** Methods whose url-encoded body PHP or Symfony parse into the request parameters. */ - private const FORM_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; - /** @var array */ private array $boot; @@ -42,13 +39,15 @@ public function create(RapiraRequest $source): DispatcherRequest $query = \explode('?', $source->target, 2)[1] ?? ''; $uploads = []; + // A body is read by its framing, never by the method name: a form is parsed for any method, where PHP + // fills `$_POST` and `$_FILES` for `POST` alone. try { if ($source->body instanceof Multipart) { $post = self::parseFields($source->body); $files = self::createFiles($source->body, $uploads); $content = ''; } else { - $post = self::parseForm($source->method, $source->body, $headers); + $post = self::parseForm($source->body, $headers); $files = []; $content = $source->body; } @@ -75,6 +74,32 @@ public function create(RapiraRequest $source): DispatcherRequest return new DispatcherRequest($request, $uploads); } + /** + * PHP drops an upload whose name has an unclosed bracket or text after a `]` instead of repairing it + * as it does for a text field: `a[b`, `a]`, `a[b]c` and `a[[b]]` never reach `$_FILES`. + * + * @psalm-pure + */ + private static function isUploadName(string $name): bool + { + $depth = 0; + for ($i = 0, $length = \strlen($name); $i < $length; ++$i) { + if ($name[$i] === '[') { + ++$depth; + } elseif ($name[$i] === ']') { + --$depth; + if ($i + 1 < $length && $name[$i + 1] !== '[') { + return false; + } + } + if ($depth < 0) { + return false; + } + } + + return $depth === 0; + } + /** * @param array> $headers * @@ -87,6 +112,13 @@ private static function mergeHeaders(array $headers): array { $merged = []; foreach ($headers as $name => $values) { + // In Worker mode the host drops a field named outside `[A-Za-z0-9-]` before filling `$_SERVER`, + // and Symfony reads `_` in a header name as `-`: `X_Forwarded_For` would pose as + // `X-Forwarded-For` in both the server and the header bag. + if (\preg_match('/^[A-Za-z0-9-]+$/D', $name) !== 1) { + continue; + } + $lower = \strtolower($name); if (isset($merged[$lower])) { $merged[$lower][1] = [...$merged[$lower][1], ...$values]; @@ -103,14 +135,11 @@ private static function mergeHeaders(array $headers): array * * @return array */ - private static function parseForm(string $method, string $body, array $headers): array + private static function parseForm(string $body, array $headers): array { - if (!\in_array($method, self::FORM_METHODS, true)) { - return []; - } - - $contentType = \strtolower(\implode(', ', $headers['content-type'][1] ?? [])); - if (\preg_match('~^application/x-www-form-urlencoded(?:$|[ ;])~', $contentType) !== 1) { + // PHP compares the media type case-insensitively, cut at the first `;`, `,` or space. + $contentType = \implode(', ', $headers['content-type'][1] ?? []); + if (\preg_match('~^application/x-www-form-urlencoded(?:$|[;, ])~i', $contentType) !== 1) { return []; } @@ -144,6 +173,9 @@ private static function createFiles(Multipart $multipart, array &$uploads): arra $pairs = []; $files = []; foreach ($multipart->files as $index => $file) { + if (!self::isUploadName($file->name)) { + continue; + } $pairs[] = \rawurlencode($file->name) . '=' . $index; $files[$index] = self::createFile($file, $uploads); } @@ -181,21 +213,37 @@ private static function createFile(RapiraUploadedFile $file, array &$uploads): U } /** - * Parses a `Cookie` header the way PHP fills `$_COOKIE`: names and values URL-decoded, names mangled - * and nested by bracket syntax, and the first of two equal names wins. + * Parses a `Cookie` header the way PHP fills `$_COOKIE`: `;`-separated pairs with leading whitespace + * dropped, names mangled and nested by bracket syntax, values raw-URL-decoded so `+` stays `+`, an + * empty value for a pair without `=`, and the first value kept for a repeated plain name. * * @return array */ private static function parseCookies(string $header): array { $pairs = []; + $seen = []; foreach (\explode(';', $header) as $pair) { - $parts = \explode('=', \trim($pair), 2); - if (\count($parts) !== 2 || $parts[0] === '' || isset($pairs[$parts[0]])) { + [$name, $value] = \explode('=', \ltrim($pair, " \t\n\r\v\f"), 2) + [1 => '']; + if ($name === '') { + continue; + } + + // The name alone through `parse_str()` gives the key PHP registers it under. + \parse_str(\rawurlencode($name), $probe); + $key = \array_key_first($probe); + if ($key === null) { + continue; + } + + // PHP drops a repeated plain name, but a bracketed one still nests into the existing key. + if (!\is_array($probe[$key]) && isset($seen[$key])) { continue; } - // `&` is a legal cookie octet but a separator for `parse_str()`. - $pairs[$parts[0]] = \str_replace('&', '%26', $parts[0]) . '=' . \str_replace('&', '%26', $parts[1]); + $seen[$key] = true; + + // Re-encoded so `parse_str()` takes `+` and `&` literally instead of as its own syntax. + $pairs[] = \rawurlencode($name) . '=' . \rawurlencode(\rawurldecode($value)); } return self::parseQuery(\implode('&', $pairs)); @@ -224,16 +272,22 @@ private static function parseQuery(string $query): array */ private function createServerParams(RapiraRequest $request, array $headers, string $query): array { + $https = $request->tls !== null || \str_starts_with($request->uri, 'https:'); + $params = [ + 'GATEWAY_INTERFACE' => 'CGI/1.1', + 'SERVER_SOFTWARE' => 'Rapira', 'SERVER_PROTOCOL' => $request->protocol, 'REQUEST_METHOD' => $request->method, + 'REQUEST_SCHEME' => $https ? 'https' : 'http', 'REQUEST_URI' => $request->target, + 'DOCUMENT_URI' => \explode('?', $request->target, 2)[0], 'QUERY_STRING' => $query, 'REQUEST_TIME' => (int) $request->receivedAt, 'REQUEST_TIME_FLOAT' => $request->receivedAt, ]; - if (\str_starts_with($request->uri, 'https:') || $request->tls !== null) { + if ($https) { $params['HTTPS'] = 'on'; } @@ -254,15 +308,13 @@ private function createServerParams(RapiraRequest $request, array $headers, stri $params['SERVER_NAME'] = \trim($host, '[]'); } - // Named as the Rapira SAPI names them in Worker mode: `-` and `.` become `_`, and of two fields that - // map to one name the later wins. + // Named as the Rapira SAPI names them in Worker mode, the content pair under both names. foreach ($headers as $lower => [, $values]) { - $key = \strtoupper(\str_replace(['-', '.'], '_', $lower)); + $key = \strtoupper(\str_replace('-', '_', $lower)); $value = \implode($lower === 'cookie' ? '; ' : ', ', $values); + $params['HTTP_' . $key] = $value; if ($key === 'CONTENT_TYPE' || $key === 'CONTENT_LENGTH') { $params[$key] = $value; - } else { - $params['HTTP_' . $key] = $value; } } diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 7eaec7a..34292c9 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -80,12 +80,18 @@ public function requestDataReachesTheApplicationWithoutTouchingTheGlobals(): voi Assert::same($request->server->get('REQUEST_TIME'), 1_700_000_000); Assert::same($request->server->get('REQUEST_TIME_FLOAT'), 1_700_000_000.5); Assert::same($request->server->get('HTTPS'), 'on'); + Assert::same($request->server->get('REQUEST_SCHEME'), 'https'); + Assert::same($request->server->get('SERVER_NAME'), 'example.test'); + Assert::same($request->server->get('QUERY_STRING'), 'q=one&q=two&raw=%2F'); + Assert::same($request->query->all(), ['q' => 'two', 'raw' => '/']); Assert::same($request->server->get('REMOTE_ADDR'), '127.0.0.1'); Assert::same($request->server->get('REMOTE_PORT'), 40000); Assert::same($request->server->get('SERVER_ADDR'), '127.0.0.1'); Assert::same($request->server->get('SERVER_PORT'), 8080); Assert::same($request->server->get('CONTENT_TYPE'), 'application/json'); Assert::same($request->server->get('CONTENT_LENGTH'), '20'); + Assert::same($request->server->get('HTTP_CONTENT_TYPE'), 'application/json'); + Assert::same($request->server->get('HTTP_CONTENT_LENGTH'), '20'); Assert::same($request->headers->all('x-repeat'), ['one', 'two']); Assert::same($request->cookies->all(), ['a' => '1', 'b' => '2']); Assert::same($request->getContent(), '{"binary":"\u0000"}'); @@ -129,7 +135,9 @@ public function requestMetadataOfTheBootIsNotInheritedAndTheListenerIsKept(): vo Assert::same($internet->server->get('SERVER_PORT'), 8443); Assert::same($internet->server->get('SERVER_PROTOCOL'), 'HTTP/1.1'); Assert::same($internet->server->get('APP_ENV'), 'test'); - foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'REMOTE_ADDR', 'REMOTE_PORT', 'REQUEST_SCHEME'] as $key) { + Assert::same($internet->server->get('REQUEST_SCHEME'), 'http'); + Assert::same($internet->server->get('SERVER_NAME'), 'public.example'); + foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'REMOTE_ADDR', 'REMOTE_PORT'] as $key) { Assert::false($internet->server->has($key), $key); } foreach (['SERVER_ADDR', 'SERVER_PORT', 'REMOTE_ADDR', 'REMOTE_PORT', 'HTTPS', 'CONTENT_TYPE', 'CONTENT_LENGTH'] as $key) { @@ -164,6 +172,114 @@ public function urlEncodedFormIsParsedWhateverTheContentTypeCase(): void Assert::same($request->request->all(), ['user' => ['name' => 'Ada']]); } + public function formMediaTypeIsCutAtTheFirstSemicolonCommaOrSpace(): void + { + $kernel = new TestKernel(); + $form = static fn(string $type, string $body): FakeExchange => FakeExchange::for('/', 'POST', ['content-type' => [$type]], $body); + + $this->serve( + $kernel, + $form('application/x-www-form-urlencoded;charset=UTF-8', 'a=1'), + $form('application/x-www-form-urlencoded,text/plain', 'a=2'), + $form('application/x-www-form-urlencoded text', 'a=3'), + $form('application/x-www-form-urlencodedx', 'a=4'), + ); + + Assert::same(\array_map(static fn(Request $request): array => $request->request->all(), $kernel->requests), [ + ['a' => '1'], + ['a' => '2'], + ['a' => '3'], + [], + ]); + } + + public function multipartBodyIsParsedWhateverTheMethod(): void + { + $kernel = new TestKernel(); + $sources = []; + $exchanges = []; + foreach (['GET', 'QUERY', 'post'] as $method) { + $sources[] = $source = self::file($method); + $exchanges[] = FakeExchange::for('/', $method, body: new Multipart( + [new FormField('field', $method, [])], + [new RapiraUploadedFile('file', 'a.txt', 'text/plain', [], $source, \strlen($method))], + )); + } + + try { + $this->serve($kernel, ...$exchanges); + } finally { + foreach ($sources as $source) { + @\unlink($source); + } + } + + foreach (['GET', 'QUERY', 'post'] as $i => $method) { + Assert::same($kernel->requests[$i]->request->all(), ['field' => $method]); + Assert::same(\array_keys($kernel->requests[$i]->files->all()), ['file']); + } + } + + public function uploadWithABrokenBracketNameIsDroppedWhereAFieldIsRepaired(): void + { + $kernel = new TestKernel(); + $sources = []; + $files = []; + foreach (['u[v', '[m]', 'a[b]c', 'a[[b]]', 'kept[x]'] as $name) { + $sources[] = $source = self::file($name); + $files[] = new RapiraUploadedFile($name, 'f.txt', 'text/plain', [], $source, \strlen($name)); + } + + try { + $this->serve($kernel, FakeExchange::for('/', 'POST', body: new Multipart([new FormField('u[v', 'text', [])], $files))); + } finally { + foreach ($sources as $source) { + @\unlink($source); + } + } + + Assert::same($kernel->requests[0]->request->all(), ['u_v' => 'text']); + Assert::same(\array_keys($kernel->requests[0]->files->all()), ['kept']); + Assert::same(\array_keys($kernel->requests[0]->files->all()['kept']), ['x']); + } + + public function headerNamedOutsideTheTokenSetCannotPoseAsAnotherHeader(): void + { + $request = $this->serveOne(FakeExchange::for('/', headers: [ + 'X_Forwarded_For' => ['203.0.113.1'], + 'X.Forwarded.For' => ['203.0.113.2'], + 'X-Real' => ['real'], + ])); + + Assert::false($request->server->has('HTTP_X_FORWARDED_FOR')); + Assert::null($request->headers->get('x-forwarded-for')); + Assert::same($request->server->get('HTTP_X_REAL'), 'real'); + Assert::same($request->headers->get('x-real'), 'real'); + } + + public function serverNameIsTheAuthorityHostWithoutPortOrBrackets(): void + { + $request = $this->serveOne(new FakeExchange(self::request(uri: 'https://[::1]:8443/', target: '/'))); + + Assert::same($request->server->get('SERVER_NAME'), '::1'); + Assert::same($request->server->get('HTTPS'), 'on'); + Assert::same($request->server->get('REQUEST_SCHEME'), 'https'); + } + + public function cookiesFollowPhpForPlusSignsBareNamesAndMangledDuplicates(): void + { + $request = $this->serveOne(FakeExchange::for('/', headers: [ + 'cookie' => ['plus=a+b; bare; a.b=first; a_b=second; list[]=1; list[]=2'], + ])); + + Assert::same($request->cookies->all(), [ + 'plus' => 'a+b', + 'bare' => '', + 'a_b' => 'first', + 'list' => ['1', '2'], + ]); + } + public function multipartFieldsAndFilesKeepTheirNestingAndUploadsAreRemovedAfterwards(): void { $source = self::file('upload-body'); @@ -283,7 +399,7 @@ public function multipartFileNamesAreMangledLikeFieldNames(): void Assert::true($request->files->all()['list_two'][0] instanceof UploadedFile); } - public function urlEncodedBodyIsParsedOnlyForTheMethodsThatTakeAForm(): void + public function urlEncodedBodyIsParsedWhateverTheMethod(): void { $form = ['content-type' => ['application/x-www-form-urlencoded']]; $kernel = new TestKernel(); @@ -292,14 +408,19 @@ public function urlEncodedBodyIsParsedOnlyForTheMethodsThatTakeAForm(): void $kernel, FakeExchange::for('/', 'PATCH', $form, 'a=1'), FakeExchange::for('/', 'GET', $form, 'a=2'), - FakeExchange::for('/', 'patch', $form, 'a=3'), + FakeExchange::for('/', 'QUERY', $form, 'a=3'), + FakeExchange::for('/', 'patch', $form, 'a=4'), + FakeExchange::for('/', 'GET', ['content-type' => ['text/plain']], 'a=5'), ); - [$patch, $get, $lowercase] = $kernel->requests; - Assert::same($patch->request->all(), ['a' => '1']); - Assert::same($get->request->all(), []); - Assert::same($get->getContent(), 'a=2'); - Assert::same($lowercase->request->all(), []); + Assert::same(\array_map(static fn(Request $request): array => $request->request->all(), $kernel->requests), [ + ['a' => '1'], + ['a' => '2'], + ['a' => '3'], + ['a' => '4'], + [], + ]); + Assert::same($kernel->requests[1]->getContent(), 'a=2'); } public function cookieValuesAreUrlDecoded(): void From 5c26a11db2dbaa4b52de60e24c05d78a352d40b6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:26:13 +0400 Subject: [PATCH 29/42] fix: send dispatcher handler output with the body and split long bodies What the application printed while handling a request, a var_dump() or a stray echo, went to the SAPI output of the dispatcher process, which reaches no client. It is now captured around handle() and sent ahead of the body, as a SAPI would and as the laravel bridge does; a response that carries a printed prefix drops its Content-Length, which no longer counts the body, and a file keeps its length and goes out without the prefix. The output of a handle() that throws is dropped with its buffers. A buffered body went to the host in one write whatever its size. Above 8 MiB it is now written in slices under a Content-Length of its own, so HTTP/1.1 stays off chunked encoding. A buffered body also no longer carries the response's own Content-Length, which the host would enforce against a body it measures itself. Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRunner.php | 26 +++++++- src/Internal/ExchangeResponseEmitter.php | 58 ++++++++++++++--- tests/Feature/DispatcherResponseTest.php | 82 ++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index fd4ecae..24f7db3 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -51,6 +51,22 @@ public function run(): int } } + /** + * Ends the buffer opened at $level + 1 and returns what was printed into it, buffers the application + * opened above it and left behind included. Nothing else reaches the client: the SAPI output of a + * dispatcher is not a response. + */ + private static function takeOutput(int $level): string + { + while (\ob_get_level() > $level + 1) { + @\ob_end_flush() or throw new OutputBufferException('A nested output buffer cannot be removed safely.'); + } + \ob_get_level() === $level + 1 + or throw new OutputBufferException('The application removed the Dispatcher output buffer.'); + + return (string) \ob_get_clean(); + } + private static function report(string $message, \Throwable $exception): void { \Rapira\log($message, LogLevel::Error, ['exception' => $exception]); @@ -76,10 +92,16 @@ private function serve(Exchange $exchange): void try { $request = $converted->request; - $response = $this->kernel->handle($request); + $level = \ob_get_level(); + \ob_start(); + try { + $response = $this->kernel->handle($request); + } finally { + $output = self::takeOutput($level); + } try { - $emitter->emit($request, $response); + $emitter->emit($request, $response, $output); } catch (ResponseDiscardedException) { } catch (UnsentResponseException $exception) { self::report('The Symfony response could not be sent.', $exception->getPrevious() ?? $exception); diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 9fe8f8e..c07bfbb 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -16,7 +16,8 @@ /** * Writes a Symfony response into one exchange: the Dispatcher-mode counterpart of {@see Response::send()}. * - * A buffered body goes out with the head. A file of a {@see BinaryFileResponse} is handed to the host with + * A buffered body goes out with the head, in one write when it fits {@see BODY_CHUNK_SIZE}. A file of a + * {@see BinaryFileResponse} is handed to the host with * {@see Exchange::sendFile()}, so PHP never holds its bytes. Anything else that prints its body is * captured from the output layer and forwarded in chunks, the head going out with the first one, so a * body that fails before printing anything can still be answered with an error. @@ -31,6 +32,9 @@ final class ExchangeResponseEmitter */ private const CHUNK_SIZE = 8192; + /** Bytes of a buffered body per write; a longer body goes out in several, under its known length. */ + private const BODY_CHUNK_SIZE = 8_388_608; + private bool $headWritten = false; /** @@ -41,14 +45,17 @@ public function __construct( ) {} /** + * + * @param string $prefix Output the application printed while it built the response. It goes out ahead of + * the body, as it would under a SAPI, except ahead of a file sent under the length prepare() set. * @throws ResponseDiscardedException The host closed the exchange: the client left or the deadline passed. * @throws UnsentResponseException The response failed before its head was written. * @throws OutputBufferException The body left the output buffer stack in a state that cannot be restored. */ - public function emit(Request $request, Response $response): void + public function emit(Request $request, Response $response, string $prefix = ''): void { try { - $this->send($request, $response); + $this->send($request, $response, $prefix); } catch (ResponseDiscardedException|OutputBufferException $exception) { throw $exception; } catch (\Throwable $exception) { @@ -99,9 +106,12 @@ private static function firstFailure(?\Throwable $first, \Throwable $next): \Thr } /** + * @param bool $keepLength False drops the response's `Content-Length`: the host computes the length of a + * body it is given whole and enforces a declared one, so a stale value would fail the response. + * * @return array> */ - private static function headers(Response $response): array + private static function headers(Response $response, bool $keepLength = true): array { $headers = []; /** @@ -109,6 +119,9 @@ private static function headers(Response $response): array * @var list $values */ foreach ($response->headers->allPreserveCaseWithoutCookies() as $name => $values) { + if (!$keepLength && \strcasecmp($name, 'Content-Length') === 0) { + continue; + } foreach ($values as $value) { $headers[$name][] = (string) $value; } @@ -121,7 +134,7 @@ private static function headers(Response $response): array return $headers; } - private function send(Request $request, Response $response): void + private function send(Request $request, Response $response, string $prefix): void { $status = $response->getStatusCode(); if ($status < 200 && $status !== 101) { @@ -165,18 +178,43 @@ private function send(Request $request, Response $response): void $content = $response->getContent(); if ($content !== false) { - $this->writeHead($status, $headers); - $this->writeBody($content); + $this->writeBuffered($status, self::headers($response, keepLength: false), $prefix . $content); return; } - $this->sendContent($response, function (string $chunk) use ($status, $headers): void { + $prefix === '' or $headers = self::headers($response, keepLength: false); + // Held back until the body prints, so a body that fails first can still be answered with an error. + $this->sendContent($response, function (string $chunk) use ($status, $headers, &$prefix): void { $this->headWritten or $this->writeHead($status, $headers); - $this->writeBody($chunk, false); + $this->writeBody($prefix . $chunk, false); + $prefix = ''; }); $this->headWritten or $this->writeHead($status, $headers); - $this->writeBody(''); + $this->writeBody($prefix); + } + + /** + * @param int<100, 599> $status + * @param array> $headers + */ + private function writeBuffered(int $status, array $headers, string $content): void + { + $length = \strlen($content); + if ($length <= self::BODY_CHUNK_SIZE) { + $this->writeHead($status, $headers); + $this->writeBody($content); + + return; + } + + // Keeps HTTP/1.1 off chunked encoding: the length is known, only the writes are split. + $headers['Content-Length'] = [(string) $length]; + $this->writeHead($status, $headers); + for ($offset = 0; $offset + self::BODY_CHUNK_SIZE < $length; $offset += self::BODY_CHUNK_SIZE) { + $this->writeBody(\substr($content, $offset, self::BODY_CHUNK_SIZE), false); + } + $this->writeBody(\substr($content, $offset)); } /** diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php index 4781bc7..c597a29 100644 --- a/tests/Feature/DispatcherResponseTest.php +++ b/tests/Feature/DispatcherResponseTest.php @@ -16,9 +16,11 @@ use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; +use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Test; @@ -52,6 +54,86 @@ public function ordinaryResponseIsWrittenWithRepeatedHeadersAndCookies(): void Assert::true($exchange->isFinalized()); } + public function bodyLongerThanOneWriteIsSplitUnderItsLength(): void + { + $exchange = FakeExchange::for('/'); + $body = \str_repeat('x', 8_388_608) . 'tail'; + + $this->serve(static fn(): Response => new Response($body), $exchange); + + Assert::same($exchange->headers['Content-Length'], [(string) \strlen($body)]); + Assert::same(\array_map(\strlen(...), $exchange->chunks), [8_388_608, 4]); + Assert::true($exchange->getBody() === $body); + Assert::true($exchange->isFinalized()); + } + + public function outputPrintedWhileHandlingLeadsTheBodyAndDropsAStaleLength(): void + { + $buffered = FakeExchange::for('/buffered'); + $streamed = FakeExchange::for('/streamed'); + $kernel = new TestKernel(static function (Request $request): Response { + echo 'pre-'; + + return $request->getPathInfo() === '/buffered' + ? new Response('body', headers: ['Content-Length' => '4']) + : new StreamedResponse(static function (): void { + echo 'body'; + }, headers: ['Content-Length' => '4']); + }); + $level = \ob_get_level(); + + $this->serveWith($kernel, $buffered, $streamed); + + Assert::same(\ob_get_level(), $level); + Assert::same($buffered->getBody(), 'pre-body'); + Assert::null($buffered->header('content-length')); + Assert::same($streamed->getBody(), 'pre-body'); + Assert::null($streamed->header('content-length')); + } + + public function outputPrintedWhileHandlingNeverLeadsAFile(): void + { + $path = self::file('0123456789'); + $exchange = FakeExchange::for('/file'); + $kernel = new TestKernel(static function () use ($path): Response { + echo 'stray'; + + return new BinaryFileResponse($path, headers: ['Content-Type' => 'text/plain']); + }); + + try { + $this->serveWith($kernel, $exchange); + } finally { + @\unlink($path); + } + + Assert::same($exchange->headers['Content-Length'], ['10']); + Assert::same($exchange->chunks, []); + Assert::count($exchange->sentFiles, 1); + } + + public function outputOfAFailedHandleIsDroppedAndTheBufferRestored(): never + { + $kernel = new TestKernel(static function (): never { + echo 'partial'; + \ob_start(); + echo 'nested'; + + throw new \RuntimeException('kernel failed'); + }); + $level = \ob_get_level(); + + Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); + \ob_start(); + try { + $this->serveWith($kernel, FakeExchange::for('/')); + } finally { + $leaked = \ob_get_clean(); + Assert::same(\ob_get_level(), $level); + Assert::same($leaked, ''); + } + } + public function headRequestKeepsThePreparedContentLengthAndSendsNoBody(): void { $exchange = FakeExchange::for('/', 'HEAD'); From fdb148f6d8773a1f809bfd30b37019478bec1a5b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:27:23 +0400 Subject: [PATCH 30/42] fix: leave the boot-time script location out of dispatcher requests Rapira serves an application from the root, with no document root and no deployment in a subfolder, yet the dispatcher request carried SCRIPT_NAME, SCRIPT_FILENAME, PHP_SELF and the rest of the script location from the boot-time $_SERVER. Symfony's base URL detection reads them, so a path starting with /index.php lost that prefix to the base URL. Without them Symfony finds an empty base URL and base path, the whole path becomes the path info, and generated URLs are root-relative. The environment, APP_ENV and APP_SECRET among it, is still carried over; Worker mode keeps the script location, which the host fills there itself. Assisted-By: Claude Opus 5.5 --- src/Internal/BootServer.php | 18 ++++++++----- src/Internal/DispatcherRequestFactory.php | 2 +- src/Internal/WorkerRunner.php | 2 +- tests/Feature/DispatcherRequestTest.php | 33 ++++++++++++++++++++++- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Internal/BootServer.php b/src/Internal/BootServer.php index b728fab..ad05e1e 100644 --- a/src/Internal/BootServer.php +++ b/src/Internal/BootServer.php @@ -5,8 +5,8 @@ namespace Rapira\Symfony\Internal; /** - * The boot-time `$_SERVER` entries a resident process hands on to every request: the environment and - * the script, never a request's metadata. + * The boot-time `$_SERVER` entries a resident process hands on to every request: the environment and, + * in Worker mode, the script, never a request's metadata. * * The host builds a request's `$_SERVER` from the request alone, so the variables Symfony's Dotenv put * there at boot, `APP_ENV` and `APP_SECRET` among them, would be gone from the second request on. @@ -16,13 +16,16 @@ */ final class BootServer { + /** Where the entry script lies, which CGI-era base URL detection reads. */ private const SCRIPT_KEYS = [ 'DOCUMENT_ROOT', + 'ORIG_SCRIPT_NAME', 'PATH_TRANSLATED', 'PHP_SELF', 'SCRIPT_FILENAME', 'SCRIPT_NAME', ]; + private const REQUEST_PREFIXES = [ 'AUTH_', 'CONTENT_', @@ -44,17 +47,20 @@ final class BootServer /** * @param array $server The `$_SERVER` of the boot. + * @param bool $script Keep the script location as well. Dispatcher mode leaves it out: Rapira serves + * from the root with no document root, so Symfony then finds an empty base URL and the whole + * path is the path info. * * @return array * * @psalm-pure */ - public static function variables(array $server): array + public static function variables(array $server, bool $script): array { $variables = []; /** @psalm-suppress MixedAssignment */ foreach ($server as $key => $value) { - if (\is_string($key) && self::isBootVariable($key)) { + if (\is_string($key) && self::isBootVariable($key, $script)) { $variables[$key] = $value; } } @@ -65,10 +71,10 @@ public static function variables(array $server): array /** * @psalm-pure */ - private static function isBootVariable(string $key): bool + private static function isBootVariable(string $key, bool $script): bool { if (\in_array($key, self::SCRIPT_KEYS, true)) { - return true; + return $script; } if (\in_array($key, self::REQUEST_KEYS, true)) { return false; diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index e24dc58..30bc6aa 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -27,7 +27,7 @@ public function __construct() { - $this->boot = BootServer::variables($_SERVER); + $this->boot = BootServer::variables($_SERVER, script: false); } /** diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index bd80882..389ddc1 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -29,7 +29,7 @@ public function run(): int { \ignore_user_abort(true); - $boot = BootServer::variables($_SERVER); + $boot = BootServer::variables($_SERVER, script: true); while ($this->serveNext($boot)) { \gc_collect_cycles(); } diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 34292c9..aa23aad 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -97,11 +97,42 @@ public function requestDataReachesTheApplicationWithoutTouchingTheGlobals(): voi Assert::same($request->getContent(), '{"binary":"\u0000"}'); Assert::same($request->server->get('APP_ENV'), 'test'); Assert::same($request->server->get('APP_CUSTOM_SETTING'), 'kept'); - Assert::same($request->server->get('SCRIPT_FILENAME'), '/app/public/index.php'); + Assert::false($request->server->has('SCRIPT_FILENAME')); Assert::null($request->headers->get('boot-only')); Assert::same([$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES], $globals); } + /** + * Rapira serves from the root, with no document root or script to locate: the base URL Symfony + * derives from the script location of the boot would only cut a prefix off the path. + */ + public function scriptLocationOfTheBootIsLeftOutSoUrlsAreRootRelative(): void + { + $_SERVER = [ + 'DOCUMENT_ROOT' => '/app/public', + 'ORIG_SCRIPT_NAME' => '/index.php', + 'PATH_TRANSLATED' => '/app/public/index.php', + 'PHP_SELF' => '/index.php', + 'SCRIPT_FILENAME' => '/app/public/index.php', + 'SCRIPT_NAME' => '/index.php', + ] + $_SERVER; + $kernel = new TestKernel(); + + $this->serve($kernel, FakeExchange::for('/blog/post?page=2'), FakeExchange::for('/index.php/blog')); + + [$path, $script] = $kernel->requests; + foreach (['DOCUMENT_ROOT', 'ORIG_SCRIPT_NAME', 'PATH_TRANSLATED', 'PHP_SELF', 'SCRIPT_FILENAME', 'SCRIPT_NAME'] as $key) { + Assert::false($path->server->has($key), $key); + } + Assert::same($path->getBaseUrl(), ''); + Assert::same($path->getBasePath(), ''); + Assert::same($path->getPathInfo(), '/blog/post'); + Assert::same($path->getUriForPath('/feed'), 'http://localhost:8080/feed'); + Assert::same($path->getUri(), 'http://localhost:8080/blog/post?page=2'); + Assert::same($script->getBaseUrl(), ''); + Assert::same($script->getPathInfo(), '/index.php/blog'); + } + public function requestMetadataOfTheBootIsNotInheritedAndTheListenerIsKept(): void { $_SERVER = [ From 90d2f69b3ad6108a03d26e1244f5d0328946ec8f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:28:13 +0400 Subject: [PATCH 31/42] docs: explain why Symfony serves dispatcher exchanges one at a time Dispatcher mode is designed for code that serves exchanges concurrently, and the README did not say that Symfony cannot: a StreamedResponse is captured through process-wide output buffers and the framework keeps request-related state in statics and shared services. It now says so and sends code that needs the superglobals or header() to Worker mode. The request and response sections follow the latest changes: the dropped header names, the script location left out, form and multipart bodies parsed for any method, handler output ahead of the body, and long bodies split into 8 MiB writes. Assisted-By: Claude Opus 5.5 --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72d5b15..7ac7659 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,15 @@ Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_*` or `HTTPS`. +## Dispatcher is sequential for Symfony + +Dispatcher mode is built for code that serves exchanges concurrently, which rules out per-request superglobals, mutable static state and `header()`. Symfony still serves them one at a time here: a `StreamedResponse` prints its body, so it can only be captured through PHP's process-wide output buffers, and the framework keeps request-related state in statics such as the trusted proxies of `Request` and in services shared by the whole process. An application, or a library, that needs the superglobals or `header()` belongs in Worker mode. + ## Dispatcher requests -The superglobals are neither read nor filled. The Symfony `Request` holds what PHP would have put there: the exact request target in `REQUEST_URI`, the method byte for byte in `REQUEST_METHOD`, every header value, query and cookie values decoded and nested by PHP's rules with the first of two equal cookie names winning, url-encoded bodies parsed for `POST`, `PUT`, `PATCH` and `DELETE`, multipart fields and files with PHP's name mangling, TLS state and both network addresses. The boot-time variables are added as in Worker mode. +The superglobals are neither read nor filled. The Symfony `Request` holds what the Rapira SAPI and PHP would have put there in Worker mode: the request target as sent in `REQUEST_URI` and `QUERY_STRING`, the method byte for byte in `REQUEST_METHOD`, every header value, query and cookie values decoded and nested by PHP's rules, form and multipart bodies parsed by their `Content-Type` for any method, with PHP's name mangling, TLS state and both network addresses. A header named outside `[A-Za-z0-9-]` is dropped, as the host drops it in Worker mode, so `X_Forwarded_For` cannot pose as `X-Forwarded-For`. The boot-time environment is added as in Worker mode, but not the script location: Rapira serves from the root, so the base URL is empty and the whole path is the path info. -Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://input`, or answers through `header()` and `echo` outside a response body, does not see the request. Symfony sessions work, `NativeSessionStorage` included: the session listener takes the session id from the request's cookies and sets the session cookie on the response. Calling `session_start()` directly does not, since PHP looks for the id in `$_COOKIE` and sends its cookie through `header()`. +Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://input`, or answers through `header()`, does not see the request. Symfony sessions work, `NativeSessionStorage` included: the session listener takes the session id from the request's cookies and sets the session cookie on the response. Calling `session_start()` directly does not, since PHP looks for the id in `$_COOKIE` and sends its cookie through `header()`. Uploaded files are renamed out of Rapira's spool, which the host empties once the response is sent, so they stay readable through `kernel->terminate()`; the Runtime removes them afterwards. A file the application moved stays where it was moved. @@ -75,7 +79,8 @@ Uploaded files are renamed out of Rapira's spool, which the host empties once th Responses are prepared by HttpFoundation, with every repeated header and `Set-Cookie` value kept. -- A buffered body goes out with the head in one write. +- Output the application prints while handling the request goes out ahead of the body, except ahead of a file. +- A buffered body goes out with the head in one write, or in 8 MiB writes under its length when longer. - A `BinaryFileResponse` is handed to Rapira with `Exchange::sendFile()`, ranges included, so PHP never holds the bytes. A temporary file, a file deleted after sending and a file Rapira refuses are streamed by PHP instead. - A `StreamedResponse`, or anything else that prints its body, is captured and forwarded every 8 KiB and on every `ob_flush()`. A bare `flush()` reaches no output handler, so call `ob_flush()` before it where latency matters. The head goes out with the first chunk. - `HEAD`, `204` and `304` send no body. A `HEAD` request for a file does not read it, and `deleteFileAfterSend()` still applies. From c0613a140f498f911426a7fec2aee84d83f8a588 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:32:45 +0400 Subject: [PATCH 32/42] chore: mark the per-request cycle collection for a configurable cleanup Assisted-By: Claude Opus 5.5 --- src/Internal/DispatcherRunner.php | 2 ++ src/Internal/WorkerRunner.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index 24f7db3..8ee059c 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -47,6 +47,8 @@ public function run(): int $this->serve($exchange); // Held across receive(), an exchange left unfinalized would stay open until the next one arrives. $exchange = null; + // TODO: a full collection after every exchange can cost more than the exchange on a large + // application; make it configurable, see https://github.com/rapira-rs/sdk-php/issues/14 \gc_collect_cycles(); } } diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index 389ddc1..7a603fd 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -31,6 +31,8 @@ public function run(): int $boot = BootServer::variables($_SERVER, script: true); while ($this->serveNext($boot)) { + // TODO: a full collection after every request can cost more than the request on a large + // application; make it configurable, see https://github.com/rapira-rs/sdk-php/issues/14 \gc_collect_cycles(); } From b3f48b3b17fc8d40836ccbce7ae682ed9fd40540 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 01:57:44 +0400 Subject: [PATCH 33/42] fix: avoid the exchange state calls that crash Rapira 0.9.0 workers on Linux On Linux, Rapira 0.9.0 corrupts the PHP VM stack around a dispatched exchange, and calling isCancelled() or isFinalized() on it in a method makes the worker segfault on every request (rapira-rs/rapira#201). The host respawns the worker silently and the client gets an empty reply, which is how the Dispatcher acceptance suite stopped coming up on the Linux CI jobs. The bridge no longer calls either. A cancelled exchange is now handled and fails its first write, which ends as a discarded response; the error answer relies on its own record of the head instead of isFinalized(). The test for skipping a cancelled exchange is skipped with its expectation kept, so reverting this commit once the host is fixed restores the check; https://github.com/rapira-rs/symfony/issues/2 tracks that. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/Internal/DispatcherRunner.php | 7 ++----- src/Internal/ExchangeResponseEmitter.php | 7 +++++-- tests/Feature/DispatcherModeTest.php | 2 ++ 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7ac7659..afbf5ee 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ The same setup stays valid outside Rapira: under php-fpm or the CLI the Runtime - **Classic** sends the response, calls `rapira_finish_request()` so the client has its answer, then runs `kernel->terminate()`. - **Worker** loops over `Rapira\handle_request()`. Per request: the request is built from the superglobals, the response is sent and finished, then terminate runs and cyclic garbage is collected. -- **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time; one cancelled while it was queued is skipped. +- **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time. Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse anything else with a `LogicException`. Rapira owns recycling and `max_requests`; the Runtime adds no request limit of its own. diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index 8ee059c..311f532 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -76,11 +76,8 @@ private static function report(string $message, \Throwable $exception): void private function serve(Exchange $exchange): void { - // The client left or the deadline passed while the exchange waited in the queue. - if ($exchange->isCancelled()) { - return; - } - + // TODO: skip a cancelled exchange with isCancelled() again once https://github.com/rapira-rs/rapira/issues/201 + // stops it crashing Linux workers; tracked in https://github.com/rapira-rs/symfony/issues/2. $emitter = new ExchangeResponseEmitter($exchange); try { diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index c07bfbb..6ad2503 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -4,6 +4,7 @@ namespace Rapira\Symfony\Internal; +use Rapira\Exception\AlreadyFinalizedError; use Rapira\Exception\WorkDiscardedException; use Rapira\Http\Exception\FileNotSendableException; use Rapira\Http\Exception\HeadAlreadyWrittenError; @@ -73,7 +74,9 @@ public function emit(Request $request, Response $response, string $prefix = ''): */ public function emitError(int $status): void { - if ($this->headWritten || $this->exchange->isFinalized()) { + // TODO: also check isFinalized() once https://github.com/rapira-rs/rapira/issues/201 stops it crashing Linux + // workers; tracked in https://github.com/rapira-rs/symfony/issues/2. + if ($this->headWritten) { return; } @@ -81,7 +84,7 @@ public function emitError(int $status): void try { $this->exchange->writeHead($status, ['Content-Type' => ['text/plain; charset=UTF-8']]); $this->exchange->writeBody(Response::$statusTexts[$status] ?? 'Error'); - } catch (WorkDiscardedException|HeadAlreadyWrittenError) { + } catch (WorkDiscardedException|HeadAlreadyWrittenError|AlreadyFinalizedError) { } } diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php index dc6ea2a..465953f 100644 --- a/tests/Feature/DispatcherModeTest.php +++ b/tests/Feature/DispatcherModeTest.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; use Testo\Expect; +use Testo\Skip; use Testo\Test; /** @@ -57,6 +58,7 @@ public function servesEveryExchangeAndTerminatesEachBeforeTheNextReceive(): void Assert::notSame($kernel->requests[0], $kernel->requests[1]); } + #[Skip('rapira-rs/rapira#201: isCancelled() crashes Linux workers, so the bridge does not call it')] public function cancelledExchangeIsSkipped(): void { $cancelled = FakeExchange::for('/cancelled'); From 0d933f624b5ab1c03085668a3d65f95cb16ccf83 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 20:10:58 +0400 Subject: [PATCH 34/42] fix: answer a failure that escapes the kernel and stop serving fix: boot the kernel before the first request in Worker and Dispatcher modes feat: let an extending runtime answer such a failure through createFailureResponse() A throwable that escapes kernel->handle() leaves the kernel in an unknown state and reached the client as the host's bare error; it now gets a plain 500, and the loop ends so Rapira runs the script afresh instead of serving on with that kernel. An error page is the bundle's call, so the runtime offers one method to override rather than rendering one itself. Rapira counts a worker that ends before its first request as a failed start and one that ends after it as a planned recycle, so the kernel boots before the loop for a broken container to be counted, as roadrunner-bundle does. Assisted-By: Claude Opus 5.5 --- README.md | 12 +- composer.json | 2 + src/Internal/DispatcherRunner.php | 52 ++++++++- src/Internal/WorkerRunner.php | 62 ++++++++++- src/Runtime.php | 17 ++- tests/Feature/DispatcherModeTest.php | 136 ++++++++++++++++++----- tests/Feature/DispatcherResponseTest.php | 30 +++-- tests/Feature/WorkerModeTest.php | 98 +++++++++++++--- tests/Support/BootRecordingKernel.php | 52 +++++++++ tests/Support/FailurePageRuntime.php | 25 +++++ tests/Support/FakeRuntimeLifecycle.php | 13 ++- 11 files changed, 424 insertions(+), 75 deletions(-) create mode 100644 tests/Support/BootRecordingKernel.php create mode 100644 tests/Support/FailurePageRuntime.php diff --git a/README.md b/README.md index afbf5ee..c44ff21 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,16 @@ Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_*` or `HTTPS`. +## Failures + +In Worker and Dispatcher modes a Symfony kernel boots before the first request, so a broken container or bundle fails the worker before it takes any work, and Rapira counts it as a failed start. In debug too: such a failure shows in Rapira's log, not in the browser. + +The kernel answers the errors of a request itself. A failure that escapes it, such as an exception thrown by a `kernel.exception` listener, leaves the kernel in an unknown state. The request is answered with a plain `500`, the failure is logged through `Rapira\log()`, and the loop ends with exit code `1`. Rapira then runs the script again in a fresh request, booting a new kernel. A failure of `kernel->terminate()` ends the loop the same way, after the client has its answer. + +To answer such a failure with an error page, extend the Runtime, override `createFailureResponse()` and name the subclass in `extra.runtime.class` of `composer.json`. If it throws, or its response cannot be sent, the client gets the plain `500`. + +In Classic mode failures are left to Symfony's error handler, as under php-fpm. + ## Dispatcher is sequential for Symfony Dispatcher mode is built for code that serves exchanges concurrently, which rules out per-request superglobals, mutable static state and `header()`. Symfony still serves them one at a time here: a `StreamedResponse` prints its body, so it can only be captured through PHP's process-wide output buffers, and the framework keeps request-related state in statics such as the trusted proxies of `Request` and in services shared by the whole process. An application, or a library, that needs the superglobals or `header()` belongs in Worker mode. @@ -87,7 +97,7 @@ Responses are prepared by HttpFoundation, with every repeated header and `Set-Co - If the client leaves mid-stream, the rest of the output is dropped and terminate still runs. The streaming code is not told: `connection_aborted()` does not reflect the exchange. - `EventStreamResponse` is answered with `500`: it closes every output buffer after each event, so nothing is left to capture the next one. Use a `StreamedResponse` that calls `ob_flush()` after each event. -A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on. A failure of the kernel, of `kernel->terminate()`, or of a response whose head is already out propagates, and Rapira replaces the worker. +A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on: the kernel was not involved. A response that fails after its head is out cannot be answered any more; it is logged and ends the loop, as described under [Failures](#failures), and Rapira fails the exchange. ## Persistent state diff --git a/composer.json b/composer.json index f304844..fa53544 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,8 @@ "roxblnfk/unpoly": "^1.8", "shipmonk/composer-dependency-analyser": "^1.8.4", "spiral/code-style": "^2.3.1", + "symfony/config": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", "testo/assert": "^0.1.17", "testo/lifecycle": "^0.1.9", "testo/skip": "^0.1.1", diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index 311f532..02ac0a6 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -9,7 +9,10 @@ use Rapira\Http\HttpDispatcher; use Rapira\LogLevel; use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; @@ -17,9 +20,14 @@ * Serves Rapira's Dispatcher mode: takes exchanges from the HTTP dispatcher one at a time and runs each * through the resident kernel. * + * The kernel boots before the first receive(), so a broken container fails the worker before it takes + * any exchange, which Rapira counts as a failed start. + * * A request that cannot be converted, or a response that fails before its head is written, is answered - * with an error and logged, and the loop goes on. A failure of the kernel, of terminate(), or of a - * response whose head is out propagates, and the host replaces the worker. + * with an error and logged, and the loop goes on. A failure that escapes the kernel, which answers its + * own errors, leaves the kernel in an unknown state: the exchange is answered with the failure response, + * the failure is logged, and the loop ends for Rapira to run the script afresh. So does a failure of + * terminate() or of a response whose head is out, unanswered. * * @internal */ @@ -27,9 +35,13 @@ { private DispatcherRequestFactory $requestFactory; + /** + * @param \Closure(\Throwable, Request): Response $failureResponse + */ public function __construct( private HttpKernelInterface $kernel, private HttpDispatcher $dispatcher, + private \Closure $failureResponse, ) { $this->requestFactory = new DispatcherRequestFactory(); } @@ -37,6 +49,8 @@ public function __construct( #[\Override] public function run(): int { + $this->kernel instanceof KernelInterface and $this->kernel->boot(); + while (true) { try { $exchange = $this->dispatcher->receive(); @@ -44,7 +58,13 @@ public function run(): int return 0; } - $this->serve($exchange); + try { + $this->serve($exchange); + } catch (\Throwable $exception) { + self::report('The worker stops serving after a failure it cannot answer for.', $exception); + + return 1; + } // Held across receive(), an exchange left unfinalized would stay open until the next one arrives. $exchange = null; // TODO: a full collection after every exchange can cost more than the exchange on a large @@ -74,6 +94,23 @@ private static function report(string $message, \Throwable $exception): void \Rapira\log($message, LogLevel::Error, ['exception' => $exception]); } + /** + * Sends the failure response, or a plain 500 if it cannot be built or sent. + */ + private function answerFailure(ExchangeResponseEmitter $emitter, \Throwable $exception, Request $request): void + { + try { + $emitter->emit($request, ($this->failureResponse)($exception, $request)); + } catch (ResponseDiscardedException) { + } catch (\Throwable) { + $emitter->emitError(500); + } + } + + /** + * @throws \Throwable A failure of the kernel, once its exchange is answered, of terminate(), or of a + * response whose head is out. + */ private function serve(Exchange $exchange): void { // TODO: skip a cancelled exchange with isCancelled() again once https://github.com/rapira-rs/rapira/issues/201 @@ -93,12 +130,21 @@ private function serve(Exchange $exchange): void $request = $converted->request; $level = \ob_get_level(); \ob_start(); + $response = $failure = null; try { $response = $this->kernel->handle($request); + } catch (\Throwable $failure) { } finally { $output = self::takeOutput($level); } + if ($response === null) { + \assert($failure !== null); + $this->answerFailure($emitter, $failure, $request); + + throw $failure; + } + try { $emitter->emit($request, $response, $output); } catch (ResponseDiscardedException) { diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index 7a603fd..eeae303 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -4,24 +4,35 @@ namespace Rapira\Symfony\Internal; +use Rapira\LogLevel; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Runtime\RunnerInterface; /** * Serves Rapira's Worker mode: the loop over `Rapira\handle_request()` with one resident kernel. * + * The kernel boots before the first request, so a broken container fails the worker before it takes + * any, which Rapira counts as a failed start. A failure that escapes the kernel, which answers its own + * errors, or its terminate() leaves the kernel in an unknown state: the request is answered with the + * failure response while it still can be, the failure is logged, and the loop ends for Rapira to run + * the script afresh. + * * @internal */ final readonly class WorkerRunner implements RunnerInterface { /** + * @param \Closure(\Throwable, Request): Response $failureResponse + * * @psalm-mutation-free */ public function __construct( private HttpKernelInterface $kernel, + private \Closure $failureResponse, ) {} #[\Override] @@ -30,10 +41,18 @@ public function run(): int \ignore_user_abort(true); $boot = BootServer::variables($_SERVER, script: true); - while ($this->serveNext($boot)) { - // TODO: a full collection after every request can cost more than the request on a large - // application; make it configurable, see https://github.com/rapira-rs/sdk-php/issues/14 - \gc_collect_cycles(); + $this->kernel instanceof KernelInterface and $this->kernel->boot(); + + try { + while ($this->serveNext($boot)) { + // TODO: a full collection after every request can cost more than the request on a large + // application; make it configurable, see https://github.com/rapira-rs/sdk-php/issues/14 + \gc_collect_cycles(); + } + } catch (\Throwable $exception) { + \Rapira\log('The worker stops serving after a failure it cannot answer for.', LogLevel::Error, ['exception' => $exception]); + + return 1; } return 0; @@ -46,6 +65,8 @@ public function run(): int * @param array $boot * * @return bool False once the host hands out no more requests. + * + * @throws \Throwable A failure of the kernel, once its request is answered, or of terminate(). */ private function serveNext(array $boot): bool { @@ -53,18 +74,32 @@ private function serveNext(array $boot): bool $request = null; /** @var Response|null $response */ $response = null; + /** @var \Throwable|null $failure */ + $failure = null; - $served = \Rapira\handle_request(function () use ($boot, &$request, &$response): bool { + $served = \Rapira\handle_request(function () use ($boot, &$request, &$response, &$failure): bool { $_SERVER += $boot; $request = Request::createFromGlobals(); - $response = $this->kernel->handle($request); + try { + $response = $this->kernel->handle($request); + } catch (\Throwable $exception) { + // Thrown out of the handler, it would reach the client as the host's bare 500. + $failure = $exception; + $this->answerFailure($exception, $request); + rapira_finish_request(); + + return true; + } + SapiResponse::send($response); rapira_finish_request(); return true; }); + $failure === null or throw $failure; + // Outside the handler: the host would answer a failure thrown in it with a 500 for a request it has // already sent, and keep serving with a kernel that failed to terminate. if ($request !== null && $response !== null && $this->kernel instanceof TerminableInterface) { @@ -73,4 +108,19 @@ private function serveNext(array $boot): bool return $served; } + + /** + * Sends the failure response, or a plain 500 if it cannot be built or sent. Once output is out, the + * head is gone with it and nothing more is sent. + */ + private function answerFailure(\Throwable $exception, Request $request): void + { + try { + if (!\headers_sent()) { + SapiResponse::send(($this->failureResponse)($exception, $request)); + } + } catch (\Throwable) { + \headers_sent() or SapiResponse::send(new Response('Internal Server Error', 500, ['Content-Type' => 'text/plain; charset=UTF-8'])); + } + } } diff --git a/src/Runtime.php b/src/Runtime.php index 144df35..5b1c69e 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -9,6 +9,8 @@ use Rapira\Symfony\Internal\ClassicRunner; use Rapira\Symfony\Internal\DispatcherRunner; use Rapira\Symfony\Internal\WorkerRunner; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Runtime\RunnerInterface; use Symfony\Component\Runtime\SymfonyRuntime; @@ -70,11 +72,22 @@ public function getRunner(?object $application): RunnerInterface return match ($this->mode) { Mode::Classic => new ClassicRunner($application, (bool) ($this->options['debug'] ?? false)), - Mode::Worker => new WorkerRunner($application), - Mode::Dispatcher => new DispatcherRunner($application, self::httpDispatcher()), + Mode::Worker => new WorkerRunner($application, $this->createFailureResponse(...)), + Mode::Dispatcher => new DispatcherRunner($application, self::httpDispatcher(), $this->createFailureResponse(...)), }; } + /** + * The answer to a failure that escaped the kernel in Worker or Dispatcher mode; the worker stops serving + * after it. Override it to render an error page: the kernel answers every other failure itself. + * + * If it throws, or its response cannot be sent, the request is answered with a plain 500. + */ + protected function createFailureResponse(\Throwable $exception, Request $request): Response + { + return new Response('Internal Server Error', 500, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + private static function httpDispatcher(): HttpDispatcher { $dispatcher = \Rapira\get_dispatcher(); diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php index 465953f..78ac610 100644 --- a/tests/Feature/DispatcherModeTest.php +++ b/tests/Feature/DispatcherModeTest.php @@ -13,6 +13,7 @@ use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Sdk\Testing\Double\Http\FakeExchange; use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; +use Rapira\Symfony\Tests\Support\BootRecordingKernel; use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Tests\Support\ForeignDispatcher; use Rapira\Symfony\Tests\Support\ScriptedExchange; @@ -21,6 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Testo\Assert; use Testo\Expect; use Testo\Skip; @@ -86,34 +88,108 @@ public function exchangeClosedByTheHostMidResponseStillTerminatesAndTheLoopGoesO Assert::true($healthy->isFinalized()); } - public function discardRaisedByTheApplicationItselfPropagates(): never + public function discardRaisedByTheApplicationItselfEndsTheLoop(): void { $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/terminate-fail'), FakeExchange::for('/must-not-run')); $kernel = new TestKernel( terminate: static fn() => throw new WorkDiscardedException('application terminate discard'), ); - Expect::exception(WorkDiscardedException::class)->withMessage('application terminate discard'); + $host = $this->serve($kernel, $dispatcher, result: 1); + + Assert::same($dispatcher->receives, 1); + Assert::count($host->logs, 1); + Assert::instanceOf($host->logs[0]['context']['exception'], WorkDiscardedException::class); + } + + public function kernelBootsBeforeTheFirstReceive(): void + { + $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/one')); + $kernel = new BootRecordingKernel(new TestKernel()); + $dispatcher->beforeReceive = static function () use ($dispatcher, $kernel): void { + $kernel->inner->events[] = 'receive:' . $dispatcher->receives; + }; + + $this->serve($kernel, $dispatcher); + + Assert::same(\array_slice($kernel->inner->events, 0, 3), ['boot', 'receive:1', 'handle:/one']); + } + + public function bootFailurePropagatesBeforeAnyExchangeIsTaken(): never + { + $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/must-not-run')); + $kernel = new BootRecordingKernel(new TestKernel(), new \RuntimeException('container broken')); + (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); + + Expect::exception(\RuntimeException::class)->withMessage('container broken'); try { - $this->serve($kernel, $dispatcher); + self::runtime()->getRunner($kernel)->run(); } finally { - Assert::same($dispatcher->receives, 1); + Assert::same($dispatcher->receives, 0); } } - public function kernelFailurePropagatesWithoutServingTheNextExchange(): never + public function kernelFailureIsAnsweredWithA500LoggedAndEndsTheLoop(): void { + $failing = FakeExchange::for('/fail'); $next = FakeExchange::for('/must-not-run'); - $dispatcher = new FakeHttpDispatcher(FakeExchange::for('/fail'), $next); + $dispatcher = new FakeHttpDispatcher($failing, $next); + $failure = new \RuntimeException('kernel failed'); + $kernel = new TestKernel(static function () use ($failure): never { + echo 'printed before'; + + throw $failure; + }); + + $host = $this->serve($kernel, $dispatcher, result: 1); + + Assert::same($failing->status, 500); + Assert::true($failing->isFinalized()); + Assert::same($failing->getBody(), 'Internal Server Error'); + Assert::same($kernel->events, ['handle:/fail']); + Assert::same($dispatcher->receives, 1); + Assert::null($next->status); + Assert::count($host->logs, 1); + Assert::same($host->logs[0]['level'], LogLevel::Error); + Assert::same($host->logs[0]['context']['exception'], $failure); + } + + public function kernelFailureIsAnsweredByAnExtendingRuntime(): void + { + $failing = FakeExchange::for('/fail'); $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); - Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); - try { - $this->serve($kernel, $dispatcher); - } finally { - Assert::same($dispatcher->receives, 1); - Assert::null($next->status); - } + $this->serve( + $kernel, + new FakeHttpDispatcher($failing), + result: 1, + failurePage: static fn(\Throwable $exception, Request $request): Response => new Response( + \sprintf('%s at %s', $exception->getMessage(), $request->getPathInfo()), + 503, + ['Retry-After' => '30'], + ), + ); + + Assert::same($failing->status, 503); + Assert::same($failing->header('Retry-After'), '30'); + Assert::same($failing->getBody(), 'kernel failed at /fail'); + } + + public function failureResponseThatFailsFallsBackToA500(): void + { + $failing = FakeExchange::for('/fail'); + $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); + + $host = $this->serve( + $kernel, + new FakeHttpDispatcher($failing), + result: 1, + failurePage: static fn(): never => throw new \LogicException('page failed'), + ); + + Assert::same($failing->status, 500); + Assert::true($failing->isFinalized()); + Assert::count($host->logs, 1); } public function exchangeIsReleasedBeforeTheNextReceive(): void @@ -229,23 +305,24 @@ public function streamFailingBeforeItPrintsIsAnsweredWithAnErrorAndStillTerminat Assert::same($kernel->events, ['handle:/stream', 'terminate:/stream', 'handle:/next', 'terminate:/next']); } - public function streamFailingAfterItsHeadIsOutPropagates(): never + public function streamFailingAfterItsHeadIsOutIsLoggedAndEndsTheLoop(): void { $failing = FakeExchange::for('/stream'); - $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function (): void { + $failure = new \RuntimeException('stream failed'); + $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function () use ($failure): void { echo 'partial'; \ob_flush(); - throw new \RuntimeException('stream failed'); + throw $failure; })); - Expect::exception(\RuntimeException::class)->withMessage('stream failed'); - try { - $this->serve($kernel, new FakeHttpDispatcher($failing, FakeExchange::for('/must-not-run'))); - } finally { - Assert::same($failing->getBody(), 'partial'); - Assert::false($failing->isFinalized()); - } + $host = $this->serve($kernel, new FakeHttpDispatcher($failing, FakeExchange::for('/must-not-run')), result: 1); + + Assert::same($failing->getBody(), 'partial'); + Assert::false($failing->isFinalized()); + Assert::same($kernel->events, ['handle:/stream']); + Assert::count($host->logs, 1); + Assert::same($host->logs[0]['context']['exception'], $failure); } /** @@ -265,11 +342,18 @@ private function assertAnsweredWith500AndTheNextServed(Exchange $failing, FakeEx Assert::instanceOf($host->logs[0]['context']['exception'], \Throwable::class); } - private function serve(TestKernel $kernel, FakeHttpDispatcher $dispatcher): FakeRuntime - { + /** + * @param null|\Closure(\Throwable, Request): Response $failurePage + */ + private function serve( + HttpKernelInterface $kernel, + FakeHttpDispatcher $dispatcher, + int $result = 0, + ?\Closure $failurePage = null, + ): FakeRuntime { $host = (new FakeRuntime(Mode::Dispatcher, $dispatcher))->install(); - Assert::same(self::runtime()->getRunner($kernel)->run(), 0); + Assert::same(self::runtime($failurePage)->getRunner($kernel)->run(), $result); return $host; } diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php index c597a29..23b3c9e 100644 --- a/tests/Feature/DispatcherResponseTest.php +++ b/tests/Feature/DispatcherResponseTest.php @@ -20,7 +20,6 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Testo\Assert; -use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Test; @@ -82,7 +81,7 @@ public function outputPrintedWhileHandlingLeadsTheBodyAndDropsAStaleLength(): vo }); $level = \ob_get_level(); - $this->serveWith($kernel, $buffered, $streamed); + Assert::same($this->serveWith($kernel, $buffered, $streamed), 0); Assert::same(\ob_get_level(), $level); Assert::same($buffered->getBody(), 'pre-body'); @@ -102,7 +101,7 @@ public function outputPrintedWhileHandlingNeverLeadsAFile(): void }); try { - $this->serveWith($kernel, $exchange); + Assert::same($this->serveWith($kernel, $exchange), 0); } finally { @\unlink($path); } @@ -112,7 +111,7 @@ public function outputPrintedWhileHandlingNeverLeadsAFile(): void Assert::count($exchange->sentFiles, 1); } - public function outputOfAFailedHandleIsDroppedAndTheBufferRestored(): never + public function outputOfAFailedHandleIsDroppedAndTheBufferRestored(): void { $kernel = new TestKernel(static function (): never { echo 'partial'; @@ -123,15 +122,15 @@ public function outputOfAFailedHandleIsDroppedAndTheBufferRestored(): never }); $level = \ob_get_level(); - Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); \ob_start(); try { - $this->serveWith($kernel, FakeExchange::for('/')); + $result = $this->serveWith($kernel, FakeExchange::for('/')); } finally { $leaked = \ob_get_clean(); Assert::same(\ob_get_level(), $level); Assert::same($leaked, ''); } + Assert::same($result, 1); } public function headRequestKeepsThePreparedContentLengthAndSendsNoBody(): void @@ -205,7 +204,7 @@ public function streamClosedByTheHostLeaksNothingAndTheLoopGoesOn(): void \ob_start(); try { - $this->serveWith($kernel, $gone, $next); + Assert::same($this->serveWith($kernel, $gone, $next), 0); Assert::same(\ob_get_contents(), ''); } finally { \ob_end_clean(); @@ -230,16 +229,13 @@ public function outputBufferTheResponseCannotRemoveFailsWithoutHanging(): void use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\StreamedResponse; - (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(FakeExchange::for('/'))))->install(); + $host = (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(FakeExchange::for('/'))))->install(); $kernel = new TestKernel(static fn() => new StreamedResponse(static function (): void { ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE); echo 'trapped'; })); - try { - (new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']))->getRunner($kernel)->run(); - } catch (LogicException $exception) { - fwrite(STDERR, $exception->getMessage()); - } + $result = (new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']))->getRunner($kernel)->run(); + fwrite(STDERR, $result . ': ' . $host->logs[0]['context']['exception']->getMessage()); PHP); try { @@ -249,7 +245,7 @@ public function outputBufferTheResponseCannotRemoveFailsWithoutHanging(): void } Assert::same($status, 0); - Assert::string($output)->contains('A nested output buffer cannot be removed safely.'); + Assert::string($output)->contains('1: A nested output buffer cannot be removed safely.'); } public function headRequestForAFileSendsItsLengthAndStillDeletesIt(): void @@ -512,13 +508,13 @@ private static function runPhpScript(string $script): array */ private function serve(\Closure $respond, FakeExchange ...$exchanges): void { - $this->serveWith(new TestKernel($respond), ...$exchanges); + Assert::same($this->serveWith(new TestKernel($respond), ...$exchanges), 0); } - private function serveWith(TestKernel $kernel, Exchange ...$exchanges): void + private function serveWith(TestKernel $kernel, Exchange ...$exchanges): int { (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install(); - Assert::same(self::runtime()->getRunner($kernel)->run(), 0); + return self::runtime()->getRunner($kernel)->run(); } } diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index d7feffe..1500faf 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -4,14 +4,17 @@ namespace Rapira\Symfony\Tests\Feature; +use Rapira\LogLevel; use Rapira\Mode; use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Sdk\Testing\Double\WorkerRequest; +use Rapira\Symfony\Tests\Support\BootRecordingKernel; use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Testo\Assert; use Testo\Expect; use Testo\Test; @@ -162,36 +165,92 @@ public function requestServedByTheLastHandleRequestCallIsTerminated(): void Assert::same($kernel->events, ['handle:/one', 'terminate:/one', 'handle:/last', 'terminate:/last']); } - public function terminateFailurePropagatesBeforeTheNextRequest(): never + public function kernelBootsBeforeTheFirstRequest(): void { - $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) - ->queue('GET', '/failure') - ->queue('GET', '/must-not-run'); - $kernel = new TestKernel(terminate: static fn() => throw new \RuntimeException('terminate failed')); + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/one'); + $kernel = new BootRecordingKernel(new TestKernel(terminate: static function () use ($runtime, &$kernel): void { + $kernel->inner->events[] = 'served:' . $runtime->servedRequests; + })); + + $this->run($runtime, $kernel); + + Assert::same($kernel->inner->events, ['boot', 'handle:/one', 'terminate:/one', 'served:1']); + } + + public function bootFailurePropagatesBeforeAnyRequestIsTaken(): never + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/must-not-run'); + $kernel = new BootRecordingKernel(new TestKernel(), new \RuntimeException('container broken')); - Expect::exception(\RuntimeException::class)->withMessage('terminate failed'); + Expect::exception(\RuntimeException::class)->withMessage('container broken'); try { $this->run($runtime, $kernel); } finally { - Assert::same($kernel->events, ['handle:/failure', 'terminate:/failure']); - Assert::same($runtime->servedRequests, 1); + Assert::same($runtime->servedRequests, 0); } } - public function kernelFailurePropagatesWithoutTerminatingOrContinuing(): never + public function kernelFailureIsAnsweredWithA500LoggedAndEndsTheLoop(): void { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) ->queue('GET', '/failure') ->queue('GET', '/must-not-run'); + $failure = new \RuntimeException('kernel failed'); + $kernel = new TestKernel(static fn() => throw $failure); + + $result = $this->run($runtime, $kernel); + + Assert::same($result, 1); + Assert::same($kernel->events, ['handle:/failure']); + Assert::same($runtime->servedRequests, 1); + Assert::same($runtime->finishedRequests, 1); + Assert::same($runtime->outputs, ['Internal Server Error']); + Assert::count($runtime->logs, 1); + Assert::same($runtime->logs[0]['level'], LogLevel::Error); + Assert::same($runtime->logs[0]['context']['exception'], $failure); + } + + public function kernelFailureIsAnsweredByAnExtendingRuntime(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/failure'); $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); - Expect::exception(\RuntimeException::class)->withMessage('kernel failed'); - try { - $this->run($runtime, $kernel); - } finally { - Assert::same($kernel->events, ['handle:/failure']); - Assert::same($runtime->servedRequests, 1); - } + $result = $this->run($runtime, $kernel, static fn(\Throwable $exception, Request $request): Response => new Response( + \sprintf('%s at %s', $exception->getMessage(), $request->getPathInfo()), + )); + + Assert::same($result, 1); + Assert::same($runtime->outputs, ['kernel failed at /failure']); + } + + public function failureResponseThatFailsFallsBackToA500(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/failure'); + $kernel = new TestKernel(static fn() => throw new \RuntimeException('kernel failed')); + + $result = $this->run($runtime, $kernel, static fn(): never => throw new \LogicException('page failed')); + + Assert::same($result, 1); + Assert::same($runtime->outputs, ['Internal Server Error']); + Assert::count($runtime->logs, 1); + } + + public function terminateFailureIsLoggedAndEndsTheLoop(): void + { + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) + ->queue('GET', '/failure') + ->queue('GET', '/must-not-run'); + $failure = new \RuntimeException('terminate failed'); + $kernel = new TestKernel(terminate: static fn() => throw $failure); + + $result = $this->run($runtime, $kernel); + + Assert::same($result, 1); + Assert::same($kernel->events, ['handle:/failure', 'terminate:/failure']); + Assert::same($runtime->outputs, ['/failure']); + Assert::same($runtime->servedRequests, 1); + Assert::count($runtime->logs, 1); + Assert::same($runtime->logs[0]['context']['exception'], $failure); } /** @@ -211,10 +270,13 @@ private static function describe(Request $request): array ]; } - private function run(FakeRuntime $runtime, TestKernel $kernel): int + /** + * @param null|\Closure(\Throwable, Request): Response $failurePage + */ + private function run(FakeRuntime $runtime, HttpKernelInterface $kernel, ?\Closure $failurePage = null): int { $runtime->install(); - return self::runtime()->getRunner($kernel)->run(); + return self::runtime($failurePage)->getRunner($kernel)->run(); } } diff --git a/tests/Support/BootRecordingKernel.php b/tests/Support/BootRecordingKernel.php new file mode 100644 index 0000000..1a88cc7 --- /dev/null +++ b/tests/Support/BootRecordingKernel.php @@ -0,0 +1,52 @@ +inner->events[] = 'boot'; + $this->bootFailure === null or throw $this->bootFailure; + } + + #[\Override] + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return $this->inner->handle($request, $type, $catch); + } + + #[\Override] + public function terminate(Request $request, Response $response): void + { + $this->inner->terminate($request, $response); + } + + #[\Override] + public function registerBundles(): iterable + { + return []; + } + + #[\Override] + public function registerContainerConfiguration(LoaderInterface $loader): void {} +} diff --git a/tests/Support/FailurePageRuntime.php b/tests/Support/FailurePageRuntime.php new file mode 100644 index 0000000..5d3cc59 --- /dev/null +++ b/tests/Support/FailurePageRuntime.php @@ -0,0 +1,25 @@ +options['failure_page']; + + return $page($exception, $request); + } +} diff --git a/tests/Support/FakeRuntimeLifecycle.php b/tests/Support/FakeRuntimeLifecycle.php index 9efeb6d..a5356c4 100644 --- a/tests/Support/FakeRuntimeLifecycle.php +++ b/tests/Support/FakeRuntimeLifecycle.php @@ -6,6 +6,8 @@ use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Symfony\Runtime; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; @@ -35,9 +37,16 @@ public function resetRuntime(): void /** * The Runtime as `autoload_runtime.php` would build it, minus the process-wide error handler and the * debug-mode `umask(0)`, which would outlive the test. + * + * @param null|\Closure(\Throwable, Request): Response $failurePage Builds a {@see FailurePageRuntime} that + * answers a failure escaping the kernel with it. */ - private static function runtime(): Runtime + private static function runtime(?\Closure $failurePage = null): Runtime { - return new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']); + $options = ['debug' => false, 'error_handler' => false, 'env' => 'test']; + + return $failurePage === null + ? new Runtime($options) + : new FailurePageRuntime($options + ['failure_page' => $failurePage]); } } From f8cb38ce59cdb6741264573a42e8851b4cc2458c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 20:11:03 +0400 Subject: [PATCH 35/42] fix: skip cancelled dispatcher exchanges again docs: require Rapira 0.9.1 Rapira 0.9.1 fixes the worker crash after isCancelled() and isFinalized() (rapira-rs/rapira#201), so the workaround from c6c60c4 is reverted; 0.9.0 still crashes on x86_64 Linux, hence the new minimum. Closes #2 Assisted-By: Claude Opus 5.5 --- README.md | 4 ++-- src/Internal/DispatcherRunner.php | 7 +++++-- src/Internal/ExchangeResponseEmitter.php | 7 ++----- tests/Feature/DispatcherModeTest.php | 2 -- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c44ff21..efc5a9b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Symfony Runtime for [Rapira](https://rapira.rs/). The same `public/index.php` se composer require rapira/symfony ``` -Requires PHP 8.4 or newer, the Symfony components `http-foundation`, `http-kernel` and `runtime` in versions `^7.4 || ^8.0`, and Rapira 0.9.0 or newer. Composer installs the PHP contract but cannot check the version of the Rapira binary. +Requires PHP 8.4 or newer, the Symfony components `http-foundation`, `http-kernel` and `runtime` in versions `^7.4 || ^8.0`, and Rapira 0.9.1 or newer: the x86_64 Linux builds of 0.9.0 crash a Dispatcher worker that checks whether its exchange is cancelled. Composer installs the PHP contract but cannot check the version of the Rapira binary. ## Application setup @@ -57,7 +57,7 @@ The same setup stays valid outside Rapira: under php-fpm or the CLI the Runtime - **Classic** sends the response, calls `rapira_finish_request()` so the client has its answer, then runs `kernel->terminate()`. - **Worker** loops over `Rapira\handle_request()`. Per request: the request is built from the superglobals, the response is sent and finished, then terminate runs and cyclic garbage is collected. -- **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time. +- **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time; one cancelled while it was queued is skipped. Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse anything else with a `LogicException`. Rapira owns recycling and `max_requests`; the Runtime adds no request limit of its own. diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index 02ac0a6..771d903 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -113,8 +113,11 @@ private function answerFailure(ExchangeResponseEmitter $emitter, \Throwable $exc */ private function serve(Exchange $exchange): void { - // TODO: skip a cancelled exchange with isCancelled() again once https://github.com/rapira-rs/rapira/issues/201 - // stops it crashing Linux workers; tracked in https://github.com/rapira-rs/symfony/issues/2. + // The client left or the deadline passed while the exchange waited in the queue. + if ($exchange->isCancelled()) { + return; + } + $emitter = new ExchangeResponseEmitter($exchange); try { diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index 6ad2503..c07bfbb 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -4,7 +4,6 @@ namespace Rapira\Symfony\Internal; -use Rapira\Exception\AlreadyFinalizedError; use Rapira\Exception\WorkDiscardedException; use Rapira\Http\Exception\FileNotSendableException; use Rapira\Http\Exception\HeadAlreadyWrittenError; @@ -74,9 +73,7 @@ public function emit(Request $request, Response $response, string $prefix = ''): */ public function emitError(int $status): void { - // TODO: also check isFinalized() once https://github.com/rapira-rs/rapira/issues/201 stops it crashing Linux - // workers; tracked in https://github.com/rapira-rs/symfony/issues/2. - if ($this->headWritten) { + if ($this->headWritten || $this->exchange->isFinalized()) { return; } @@ -84,7 +81,7 @@ public function emitError(int $status): void try { $this->exchange->writeHead($status, ['Content-Type' => ['text/plain; charset=UTF-8']]); $this->exchange->writeBody(Response::$statusTexts[$status] ?? 'Error'); - } catch (WorkDiscardedException|HeadAlreadyWrittenError|AlreadyFinalizedError) { + } catch (WorkDiscardedException|HeadAlreadyWrittenError) { } } diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php index 78ac610..999c97e 100644 --- a/tests/Feature/DispatcherModeTest.php +++ b/tests/Feature/DispatcherModeTest.php @@ -25,7 +25,6 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Testo\Assert; use Testo\Expect; -use Testo\Skip; use Testo\Test; /** @@ -60,7 +59,6 @@ public function servesEveryExchangeAndTerminatesEachBeforeTheNextReceive(): void Assert::notSame($kernel->requests[0], $kernel->requests[1]); } - #[Skip('rapira-rs/rapira#201: isCancelled() crashes Linux workers, so the bridge does not call it')] public function cancelledExchangeIsSkipped(): void { $cancelled = FakeExchange::for('/cancelled'); From 88ad4a4850898ee0575b3d0be7e1c2d6ab18cfd8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 7 Oct 2026 22:24:04 +0400 Subject: [PATCH 36/42] docs: say that REMOTE_ADDR in trusted_proxies trusts no proxy now that the kernel boots before the loop Assisted-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efc5a9b..cf2dc9a 100644 --- a/README.md +++ b/README.md @@ -103,4 +103,4 @@ A request that cannot be converted, and a response that fails before its head is In Worker and Dispatcher modes the process, kernel, container and services outlive each request. Keep request state out of static properties and long-lived services; Symfony resets services tagged `kernel.reset` between requests, anything else is the application's to reset. -The kernel boots once, on the first request, and `Kernel::boot()` resolves the `REMOTE_ADDR` placeholder of `trusted_proxies` from `$_SERVER` at that moment. In Worker mode that makes the first client's address a trusted proxy for the life of the worker; in Dispatcher mode it resolves against the boot-time `$_SERVER`, which normally holds no client address. List the proxy addresses or subnets explicitly, or use `PRIVATE_SUBNETS`, instead of `REMOTE_ADDR`. +The kernel boots once, before the first request, and `Kernel::boot()` resolves the `REMOTE_ADDR` placeholder of `trusted_proxies` from `$_SERVER` at that moment. The boot-time `$_SERVER` holds no client address, so the placeholder is dropped and trusts no proxy for the life of the worker. List the proxy addresses or subnets explicitly, or use `PRIVATE_SUBNETS`, instead of `REMOTE_ADDR`. From 6d71df06c8534917131e9c84f44a69b1830834bf Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 8 Oct 2026 15:42:55 +0400 Subject: [PATCH 37/42] fix: put loopback in REMOTE_ADDR for a unix peer, as the SAPI does fix: leave the client's authority out of SERVER_NAME Behind a proxy on a unix socket, REMOTE_ADDR held nothing or the socket path, so trusted_proxies matching on PRIVATE_SUBNETS failed in Dispatcher mode while Worker mode trusted the proxy. The SAPI sets SERVER_NAME from the configured server name, which a dispatcher cannot see; filling it from the authority the client sent passed a client value off as a server one. Same change as rapira/http 0.1.6. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/Internal/DispatcherRequestFactory.php | 12 ++++++------ tests/Feature/DispatcherRequestTest.php | 17 +++++++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf2dc9a..85720cf 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Dispatcher mode is built for code that serves exchanges concurrently, which rule ## Dispatcher requests -The superglobals are neither read nor filled. The Symfony `Request` holds what the Rapira SAPI and PHP would have put there in Worker mode: the request target as sent in `REQUEST_URI` and `QUERY_STRING`, the method byte for byte in `REQUEST_METHOD`, every header value, query and cookie values decoded and nested by PHP's rules, form and multipart bodies parsed by their `Content-Type` for any method, with PHP's name mangling, TLS state and both network addresses. A header named outside `[A-Za-z0-9-]` is dropped, as the host drops it in Worker mode, so `X_Forwarded_For` cannot pose as `X-Forwarded-For`. The boot-time environment is added as in Worker mode, but not the script location: Rapira serves from the root, so the base URL is empty and the whole path is the path info. +The superglobals are neither read nor filled. The Symfony `Request` holds what the Rapira SAPI and PHP would have put there in Worker mode: the request target as sent in `REQUEST_URI` and `QUERY_STRING`, the method byte for byte in `REQUEST_METHOD`, every header value, query and cookie values decoded and nested by PHP's rules, form and multipart bodies parsed by their `Content-Type` for any method, with PHP's name mangling, TLS state and both network addresses, a unix peer as `127.0.0.1`. `SERVER_NAME` is left unset: the SAPI takes it from `[http] server_name`, which a dispatcher does not see, and the client-sent authority stays in `HTTP_HOST`. A header named outside `[A-Za-z0-9-]` is dropped, as the host drops it in Worker mode, so `X_Forwarded_For` cannot pose as `X-Forwarded-For`. The boot-time environment is added as in Worker mode, but not the script location: Rapira serves from the root, so the base URL is empty and the whole path is the path info. Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://input`, or answers through `header()`, does not see the request. Symfony sessions work, `NativeSessionStorage` included: the session listener takes the session id from the request's cookies and sets the session cookie on the response. Calling `session_start()` directly does not, since PHP looks for the id in `$_COOKIE` and sends its cookie through `header()`. diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 30bc6aa..8899ddb 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -294,8 +294,10 @@ private function createServerParams(RapiraRequest $request, array $headers, stri if ($request->remote instanceof InetAddress) { $params['REMOTE_ADDR'] = $request->remote->ip; $params['REMOTE_PORT'] = $request->remote->port; - } elseif ($request->remote->path !== null) { - $params['REMOTE_ADDR'] = $request->remote->path; + } else { + // REMOTE_ADDR must hold a host number (RFC 3875 §4.1.8), so the SAPI puts loopback in for a unix peer. + $params['REMOTE_ADDR'] = '127.0.0.1'; + $params['REMOTE_PORT'] = 0; } if ($request->server instanceof InetAddress) { @@ -303,10 +305,8 @@ private function createServerParams(RapiraRequest $request, array $headers, stri $params['SERVER_PORT'] = $request->server->port; } - $host = \parse_url($request->uri, \PHP_URL_HOST); - if (\is_string($host) && $host !== '') { - $params['SERVER_NAME'] = \trim($host, '[]'); - } + // SERVER_NAME is left out: the SAPI sets the configured server name, which a dispatcher does not see, and + // the authority the client sent must not pass for a value the server controls. // Named as the Rapira SAPI names them in Worker mode, the content pair under both names. foreach ($headers as $lower => [, $values]) { diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index aa23aad..0031afb 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -81,7 +81,7 @@ public function requestDataReachesTheApplicationWithoutTouchingTheGlobals(): voi Assert::same($request->server->get('REQUEST_TIME_FLOAT'), 1_700_000_000.5); Assert::same($request->server->get('HTTPS'), 'on'); Assert::same($request->server->get('REQUEST_SCHEME'), 'https'); - Assert::same($request->server->get('SERVER_NAME'), 'example.test'); + Assert::false($request->server->has('SERVER_NAME')); Assert::same($request->server->get('QUERY_STRING'), 'q=one&q=two&raw=%2F'); Assert::same($request->query->all(), ['q' => 'two', 'raw' => '/']); Assert::same($request->server->get('REMOTE_ADDR'), '127.0.0.1'); @@ -167,13 +167,18 @@ public function requestMetadataOfTheBootIsNotInheritedAndTheListenerIsKept(): vo Assert::same($internet->server->get('SERVER_PROTOCOL'), 'HTTP/1.1'); Assert::same($internet->server->get('APP_ENV'), 'test'); Assert::same($internet->server->get('REQUEST_SCHEME'), 'http'); - Assert::same($internet->server->get('SERVER_NAME'), 'public.example'); - foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'REMOTE_ADDR', 'REMOTE_PORT'] as $key) { + foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'SERVER_NAME'] as $key) { Assert::false($internet->server->has($key), $key); } - foreach (['SERVER_ADDR', 'SERVER_PORT', 'REMOTE_ADDR', 'REMOTE_PORT', 'HTTPS', 'CONTENT_TYPE', 'CONTENT_LENGTH'] as $key) { + foreach (['SERVER_ADDR', 'SERVER_PORT', 'HTTPS', 'CONTENT_TYPE', 'CONTENT_LENGTH'] as $key) { Assert::false($unix->server->has($key), $key); } + // A unix peer is loopback, as the SAPI registers it, never the boot-time address. + foreach ([$internet, $unix] as $request) { + Assert::same($request->server->get('REMOTE_ADDR'), '127.0.0.1'); + Assert::same($request->server->get('REMOTE_PORT'), 0); + Assert::same($request->getClientIp(), '127.0.0.1'); + } } public function urlEncodedFormIsParsedIntoNestedFields(): void @@ -288,11 +293,11 @@ public function headerNamedOutsideTheTokenSetCannotPoseAsAnotherHeader(): void Assert::same($request->headers->get('x-real'), 'real'); } - public function serverNameIsTheAuthorityHostWithoutPortOrBrackets(): void + public function authorityNeverBecomesTheServerName(): void { $request = $this->serveOne(new FakeExchange(self::request(uri: 'https://[::1]:8443/', target: '/'))); - Assert::same($request->server->get('SERVER_NAME'), '::1'); + Assert::false($request->server->has('SERVER_NAME')); Assert::same($request->server->get('HTTPS'), 'on'); Assert::same($request->server->get('REQUEST_SCHEME'), 'https'); } From d1328db42ffccc075ab9fd4ccb6c6002f319c6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Fri, 9 Oct 2026 17:47:59 +0400 Subject: [PATCH 38/42] fix: run terminate inside the Worker request handler fix: keep a Dispatcher worker serving after a stream fails past its head fix: keep application variables named like CGI ones in a request's $_SERVER fix: stream a file response through PHP when Symfony's internal state is gone fix: truncate input past PHP's limits instead of failing under the debug handler fix: leave an empty upload input null in Dispatcher mode, as FileBag does After handle_request() returns, the host has already removed the uploads and lifted max_execution_time, so terminate listeners lost the files and could hang the worker. A body that fails after its head leaves the kernel intact, so only a damaged output buffer stack still ends the loop. Assisted-By: Claude Opus 5.5 --- README.md | 25 ++++-- src/Internal/BootServer.php | 40 ++++++---- src/Internal/DispatcherRequestFactory.php | 18 ++++- src/Internal/DispatcherRunner.php | 13 +++- src/Internal/ExchangeResponseEmitter.php | 8 ++ src/Internal/WorkerRunner.php | 22 +++--- tests/Acceptance/DispatcherModeTest.php | 23 ++++++ tests/Acceptance/Support/ServerRequests.php | 5 +- tests/Acceptance/WorkerModeTest.php | 30 ++++++++ tests/App/Kernel.php | 52 ++++++++++++- tests/Feature/DispatcherModeTest.php | 22 ++++-- tests/Feature/DispatcherRequestTest.php | 77 ++++++++++++++++++- tests/Feature/DispatcherResponseTest.php | 51 +----------- .../Feature/FileResponseCompatibilityTest.php | 30 ++++++++ tests/Feature/WorkerModeTest.php | 22 +++++- .../Fixtures/file-response-compatibility.php | 38 +++++++++ .../FileResponseWithoutInternalState.php | 20 +++++ tests/Support/PhpProcess.php | 60 +++++++++++++++ 18 files changed, 450 insertions(+), 106 deletions(-) create mode 100644 tests/Feature/FileResponseCompatibilityTest.php create mode 100644 tests/Fixtures/file-response-compatibility.php create mode 100644 tests/Support/FileResponseWithoutInternalState.php create mode 100644 tests/Support/PhpProcess.php diff --git a/README.md b/README.md index 85720cf..ecbf46c 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ The same setup stays valid outside Rapira: under php-fpm or the CLI the Runtime ## Execution modes - **Classic** sends the response, calls `rapira_finish_request()` so the client has its answer, then runs `kernel->terminate()`. -- **Worker** loops over `Rapira\handle_request()`. Per request: the request is built from the superglobals, the response is sent and finished, then terminate runs and cyclic garbage is collected. +- **Worker** loops over `Rapira\handle_request()`. Per request: the request is built from the superglobals, the response is sent and finished, then terminate runs inside the request handler while uploads and the execution timeout remain available. Cyclic garbage is collected after the handler returns. - **Dispatcher** takes each `Rapira\Http\Exchange` from the HTTP dispatcher, converts it to a Symfony `Request` and writes the response straight into the exchange. Exchanges are served one at a time; one cancelled while it was queued is skipped. Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse anything else with a `LogicException`. Rapira owns recycling and `max_requests`; the Runtime adds no request limit of its own. -In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_*` or `HTTPS`. +In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_ADDR` or `HTTPS`. Application variables such as `SERVER_ROLE` and `REQUEST_TIMEOUT` are preserved. ## Failures @@ -83,7 +83,9 @@ The superglobals are neither read nor filled. The Symfony `Request` holds what t Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://input`, or answers through `header()`, does not see the request. Symfony sessions work, `NativeSessionStorage` included: the session listener takes the session id from the request's cookies and sets the session cookie on the response. Calling `session_start()` directly does not, since PHP looks for the id in `$_COOKIE` and sends its cookie through `header()`. -Uploaded files are renamed out of Rapira's spool, which the host empties once the response is sent, so they stay readable through `kernel->terminate()`; the Runtime removes them afterwards. A file the application moved stays where it was moved. +Uploaded files are renamed out of Rapira's spool, which the host empties once the response is sent, so they stay readable through `kernel->terminate()`; the Runtime removes them afterwards. A file the application moved stays where it was moved. Empty upload inputs are normalized by Symfony's `FileBag`, just as in Classic and Worker modes. + +Input exceeding PHP's parsing limits (such as `max_input_vars`) is truncated as in PHP's startup parsing, including when Symfony's debug error handler is enabled. ## Dispatcher responses @@ -91,16 +93,29 @@ Responses are prepared by HttpFoundation, with every repeated header and `Set-Co - Output the application prints while handling the request goes out ahead of the body, except ahead of a file. - A buffered body goes out with the head in one write, or in 8 MiB writes under its length when longer. -- A `BinaryFileResponse` is handed to Rapira with `Exchange::sendFile()`, ranges included, so PHP never holds the bytes. A temporary file, a file deleted after sending and a file Rapira refuses are streamed by PHP instead. +- A `BinaryFileResponse` is handed to Rapira with `Exchange::sendFile()`, ranges included, so PHP never holds the bytes. A temporary file, a file deleted after sending and a file Rapira refuses are streamed by PHP instead. The same fallback applies if a Symfony version changes the internal properties needed for the optimization. - A `StreamedResponse`, or anything else that prints its body, is captured and forwarded every 8 KiB and on every `ob_flush()`. A bare `flush()` reaches no output handler, so call `ob_flush()` before it where latency matters. The head goes out with the first chunk. - `HEAD`, `204` and `304` send no body. A `HEAD` request for a file does not read it, and `deleteFileAfterSend()` still applies. - If the client leaves mid-stream, the rest of the output is dropped and terminate still runs. The streaming code is not told: `connection_aborted()` does not reflect the exchange. - `EventStreamResponse` is answered with `500`: it closes every output buffer after each event, so nothing is left to capture the next one. Use a `StreamedResponse` that calls `ob_flush()` after each event. -A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on: the kernel was not involved. A response that fails after its head is out cannot be answered any more; it is logged and ends the loop, as described under [Failures](#failures), and Rapira fails the exchange. +A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on: the kernel was not involved. A response that fails after its head is out cannot be answered any more; it is logged, Rapira fails the unfinished exchange, and termination runs before the worker serves the next request. A damaged output buffer stack still ends the loop because it cannot be reused safely. ## Persistent state In Worker and Dispatcher modes the process, kernel, container and services outlive each request. Keep request state out of static properties and long-lived services; Symfony resets services tagged `kernel.reset` between requests, anything else is the application's to reset. The kernel boots once, before the first request, and `Kernel::boot()` resolves the `REMOTE_ADDR` placeholder of `trusted_proxies` from `$_SERVER` at that moment. The boot-time `$_SERVER` holds no client address, so the placeholder is dropped and trusts no proxy for the life of the worker. List the proxy addresses or subnets explicitly, or use `PRIVATE_SUBNETS`, instead of `REMOTE_ADDR`. + +## Development + +The feature and live-server acceptance suites use Testo: + +```sh +composer install +composer test +composer test:feature +composer test:acceptance +``` + +The acceptance suite downloads Rapira and its bundled PHP into `runtime/bin` when missing. The `rapira/testing` Testo integration starts a fixture app in Classic, Worker and Dispatcher modes and stops each server after its test case. The first run needs network access to download the binary; acceptance tests need permission to bind local ports. diff --git a/src/Internal/BootServer.php b/src/Internal/BootServer.php index ad05e1e..5be5d57 100644 --- a/src/Internal/BootServer.php +++ b/src/Internal/BootServer.php @@ -26,23 +26,37 @@ final class BootServer 'SCRIPT_NAME', ]; - private const REQUEST_PREFIXES = [ - 'AUTH_', - 'CONTENT_', - 'HTTP_', - 'PHP_AUTH_', - 'REMOTE_', - 'REQUEST_', - 'SERVER_', - ]; + // Exact CGI names registered by Rapira's SAPI and PHP itself. Prefixes such as SERVER_ or + // CONTENT_ also belong to legitimate application settings and must not be discarded. private const REQUEST_KEYS = [ + 'AUTH_TYPE', + 'CONTENT_LENGTH', + 'CONTENT_TYPE', 'DOCUMENT_URI', 'GATEWAY_INTERFACE', 'HTTPS', 'ORIG_PATH_INFO', 'PATH_INFO', + 'PHP_AUTH_DIGEST', + 'PHP_AUTH_PW', + 'PHP_AUTH_USER', 'QUERY_STRING', 'REDIRECT_STATUS', + 'REMOTE_ADDR', + 'REMOTE_HOST', + 'REMOTE_IDENT', + 'REMOTE_PORT', + 'REMOTE_USER', + 'REQUEST_METHOD', + 'REQUEST_SCHEME', + 'REQUEST_TIME', + 'REQUEST_TIME_FLOAT', + 'REQUEST_URI', + 'SERVER_ADDR', + 'SERVER_NAME', + 'SERVER_PORT', + 'SERVER_PROTOCOL', + 'SERVER_SOFTWARE', ]; /** @@ -80,12 +94,6 @@ private static function isBootVariable(string $key, bool $script): bool return false; } - foreach (self::REQUEST_PREFIXES as $prefix) { - if (\str_starts_with($key, $prefix)) { - return false; - } - } - - return true; + return !\str_starts_with($key, 'HTTP_'); } } diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 8899ddb..635581e 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -190,11 +190,14 @@ private static function createFiles(Multipart $multipart, array &$uploads): arra /** * @param list $uploads + * @return UploadedFile|array{name: string, type: string, tmp_name: string, error: int, size: int} */ - private static function createFile(RapiraUploadedFile $file, array &$uploads): UploadedFile + private static function createFile(RapiraUploadedFile $file, array &$uploads): UploadedFile|array { if ($file->clientFilename === '') { - return new UploadedFile('', '', null, \UPLOAD_ERR_NO_FILE, true); + // FileBag normalizes this to null and removes empty entries from upload lists. + // Passing null directly is not allowed by FileBag::set(). + return ['name' => '', 'type' => '', 'tmp_name' => '', 'error' => \UPLOAD_ERR_NO_FILE, 'size' => 0]; } // The host deletes its spool file once the exchange finalizes, which is before terminate(); a file @@ -230,7 +233,7 @@ private static function parseCookies(string $header): array } // The name alone through `parse_str()` gives the key PHP registers it under. - \parse_str(\rawurlencode($name), $probe); + $probe = self::parseQuery(\rawurlencode($name)); $key = \array_key_first($probe); if ($key === null) { continue; @@ -258,7 +261,14 @@ private static function parseQuery(string $query): array return []; } - \parse_str($query, $result); + // Match PHP's startup parsing: input beyond its limits is truncated, not turned into a + // server error by Symfony's debug error handler. Restore that handler even if parsing fails. + \set_error_handler(static fn(): bool => true, \E_WARNING); + try { + \parse_str($query, $result); + } finally { + \restore_error_handler(); + } return $result; } diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php index 771d903..429324b 100644 --- a/src/Internal/DispatcherRunner.php +++ b/src/Internal/DispatcherRunner.php @@ -27,7 +27,8 @@ * with an error and logged, and the loop goes on. A failure that escapes the kernel, which answers its * own errors, leaves the kernel in an unknown state: the exchange is answered with the failure response, * the failure is logged, and the loop ends for Rapira to run the script afresh. So does a failure of - * terminate() or of a response whose head is out, unanswered. + * terminate() or a damaged output buffer stack. Other response failures still terminate the request + * and leave the worker available for the next exchange. * * @internal */ @@ -108,8 +109,8 @@ private function answerFailure(ExchangeResponseEmitter $emitter, \Throwable $exc } /** - * @throws \Throwable A failure of the kernel, once its exchange is answered, of terminate(), or of a - * response whose head is out. + * @throws \Throwable A failure of the kernel, once its exchange is answered, of terminate(), or of + * the output buffer stack. */ private function serve(Exchange $exchange): void { @@ -154,6 +155,12 @@ private function serve(Exchange $exchange): void } catch (UnsentResponseException $exception) { self::report('The Symfony response could not be sent.', $exception->getPrevious() ?? $exception); $emitter->emitError(500); + } catch (OutputBufferException $exception) { + throw $exception; + } catch (\Throwable $exception) { + // The head is already out. Leave the exchange unfinalized for the host to fail, + // but a failed body does not invalidate the kernel or skip its termination. + self::report('The Symfony response could not be sent.', $exception); } if ($this->kernel instanceof TerminableInterface) { diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index c07bfbb..dcfbc17 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -225,6 +225,14 @@ private function writeBuffered(int $status, array $headers, string $content): vo */ private function sendFile(BinaryFileResponse $response, int $status, array $headers): bool { + // These properties are Symfony internals, not a compatibility promise. Its public + // sendContent() remains usable if a future version changes the optimized file state. + foreach (['offset', 'maxlen', 'deleteFileAfterSend', 'tempFileObject'] as $property) { + if (!\property_exists(BinaryFileResponse::class, $property)) { + return false; + } + } + /** @var int $offset */ $offset = self::fileState($response, 'offset'); /** @var int $maxlen */ diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index eeae303..346cab4 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -70,14 +70,10 @@ public function run(): int */ private function serveNext(array $boot): bool { - /** @var Request|null $request */ - $request = null; - /** @var Response|null $response */ - $response = null; /** @var \Throwable|null $failure */ $failure = null; - $served = \Rapira\handle_request(function () use ($boot, &$request, &$response, &$failure): bool { + $served = \Rapira\handle_request(function () use ($boot, &$failure): bool { $_SERVER += $boot; $request = Request::createFromGlobals(); @@ -95,17 +91,21 @@ private function serveNext(array $boot): bool SapiResponse::send($response); rapira_finish_request(); + // Keep the host's timeout and uploaded files alive until termination is complete. + // Rethrow only after handle_request() returns: the client already has its response. + if ($this->kernel instanceof TerminableInterface) { + try { + $this->kernel->terminate($request, $response); + } catch (\Throwable $exception) { + $failure = $exception; + } + } + return true; }); $failure === null or throw $failure; - // Outside the handler: the host would answer a failure thrown in it with a 500 for a request it has - // already sent, and keep serving with a kernel that failed to terminate. - if ($request !== null && $response !== null && $this->kernel instanceof TerminableInterface) { - $this->kernel->terminate($request, $response); - } - return $served; } diff --git a/tests/Acceptance/DispatcherModeTest.php b/tests/Acceptance/DispatcherModeTest.php index 9f0b3e6..549400e 100644 --- a/tests/Acceptance/DispatcherModeTest.php +++ b/tests/Acceptance/DispatcherModeTest.php @@ -7,6 +7,7 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; +use Testo\Assert; use Testo\Test; /** @@ -25,6 +26,28 @@ public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void $this->assertFlushingStreamArrivesWhole(sameWorker: true); } + public function failedStreamTerminatesWithoutRestartingTheWorker(): void + { + [, $boot] = $this->request('/boot'); + $body = ''; + $curl = \curl_init('http://' . self::ADDRESS . '/broken-stream'); + \curl_setopt_array($curl, [ + \CURLOPT_TIMEOUT => 10, + \CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $chunk) use (&$body): int { + $body .= $chunk; + + return \strlen($chunk); + }, + ]); + + Assert::false(\curl_exec($curl), 'An unfinished response must fail on the wire.'); + Assert::same(\curl_getinfo($curl, \CURLINFO_HTTP_CODE), 200); + Assert::same($body, 'partial'); + Assert::same($this->request('/boot'), [200, $boot]); + [, $terminated] = $this->request('/termination'); + Assert::same(\json_decode($terminated, true, flags: \JSON_THROW_ON_ERROR), ['stream' => true]); + } + protected function mode(): Mode { return Mode::Dispatcher; diff --git a/tests/Acceptance/Support/ServerRequests.php b/tests/Acceptance/Support/ServerRequests.php index dba9b6c..8d489a4 100644 --- a/tests/Acceptance/Support/ServerRequests.php +++ b/tests/Acceptance/Support/ServerRequests.php @@ -50,12 +50,13 @@ private function assertFlushingStreamArrivesWhole(bool $sameWorker): void } /** + * @param array $options Additional cURL options, for example a multipart upload. * @return array{0: int, 1: string} The response status code and body. */ - private function request(string $path): array + private function request(string $path, array $options = []): array { $curl = \curl_init(); - \curl_setopt_array($curl, [ + \curl_setopt_array($curl, $options + [ \CURLOPT_URL => 'http://' . self::ADDRESS . $path, \CURLOPT_RETURNTRANSFER => true, \CURLOPT_TIMEOUT => 10, diff --git a/tests/Acceptance/WorkerModeTest.php b/tests/Acceptance/WorkerModeTest.php index 0263eae..9ae16ba 100644 --- a/tests/Acceptance/WorkerModeTest.php +++ b/tests/Acceptance/WorkerModeTest.php @@ -7,6 +7,7 @@ use Rapira\Sdk\Common\Mode; use Rapira\Sdk\Testing\Testo\Attribute\RunRapira; use Rapira\Symfony\Tests\Acceptance\Support\ServerRequests; +use Testo\Assert; use Testo\Test; /** @@ -25,6 +26,35 @@ public function flushingStreamArrivesWholeAndTheApplicationGoesOn(): void $this->assertFlushingStreamArrivesWhole(sameWorker: true); } + public function uploadedFileRemainsReadableDuringTerminate(): void + { + $path = \tempnam(\sys_get_temp_dir(), 'rapira-upload-test-'); + \file_put_contents($path, 'upload survives until terminate'); + try { + Assert::same($this->request('/upload', [ + \CURLOPT_POST => true, + \CURLOPT_POSTFIELDS => ['file' => new \CURLFile($path, 'text/plain', 'note.txt')], + ]), [200, 'uploaded']); + [$status, $body] = $this->request('/termination'); + } finally { + @\unlink($path); + } + + Assert::same($status, 200); + Assert::same(\json_decode($body, true, flags: \JSON_THROW_ON_ERROR), ['exists' => true, 'content' => 'upload survives until terminate']); + } + + public function executionTimeoutStillAppliesDuringTerminate(): void + { + [, $boot] = $this->request('/boot'); + + Assert::same($this->request('/terminate-timeout'), [200, $boot]); + [$status, $nextBoot] = $this->request('/boot'); + + Assert::same($status, 200); + Assert::notSame($nextBoot, $boot, 'The timed-out worker must be replaced before serving another request.'); + } + protected function mode(): Mode { return Mode::Worker; diff --git a/tests/App/Kernel.php b/tests/App/Kernel.php index 047c36a..05a7f07 100644 --- a/tests/App/Kernel.php +++ b/tests/App/Kernel.php @@ -4,19 +4,28 @@ namespace Rapira\Symfony\Tests\App; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; /** * The smallest HTTP kernel the acceptance suite can serve through the Runtime in every Rapira mode. */ -final class Kernel implements HttpKernelInterface +final class Kernel implements HttpKernelInterface, TerminableInterface { /** Tells one boot of the application from the next: a resident worker keeps it across requests. */ private readonly string $bootId; + /** + * What the last terminate() saw, for `/termination` to report. + * + * @var array + */ + private array $lastTermination = []; + public function __construct() { $this->bootId = \bin2hex(\random_bytes(8)); @@ -29,6 +38,15 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c '/' => new Response('OK'), '/mode' => new Response(\strtolower(\Rapira\get_mode()->name)), '/boot' => new Response($this->bootId), + '/upload' => new Response('uploaded'), + '/termination' => new JsonResponse($this->lastTermination), + '/terminate-timeout' => $this->timedResponse(), + '/broken-stream' => new StreamedResponse(static function (): never { + echo 'partial'; + \ob_flush(); + + throw new \RuntimeException('stream failed after its head'); + }), '/stream' => new StreamedResponse(static function (): void { echo 'one'; \ob_flush(); @@ -38,4 +56,36 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c default => new Response('Not Found', Response::HTTP_NOT_FOUND), }; } + + #[\Override] + public function terminate(Request $request, Response $response): void + { + if ($request->getPathInfo() === '/broken-stream') { + $this->lastTermination = ['stream' => true]; + } + + if ($request->getPathInfo() === '/upload') { + $file = $request->files->get('file'); + $exists = $file !== null && \is_file($file->getPathname()); + $this->lastTermination = [ + 'exists' => $exists, + 'content' => $exists ? \file_get_contents($file->getPathname()) : null, + ]; + } + + if ($request->getPathInfo() === '/terminate-timeout') { + // CPU work must hit max_execution_time. Bound it so a regression cannot hang CI. + $deadline = \microtime(true) + 3.0; + while (\microtime(true) < $deadline) { + } + \set_time_limit(0); + } + } + + private function timedResponse(): Response + { + \set_time_limit(1); + + return new Response($this->bootId); + } } diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php index 999c97e..426be19 100644 --- a/tests/Feature/DispatcherModeTest.php +++ b/tests/Feature/DispatcherModeTest.php @@ -303,22 +303,28 @@ public function streamFailingBeforeItPrintsIsAnsweredWithAnErrorAndStillTerminat Assert::same($kernel->events, ['handle:/stream', 'terminate:/stream', 'handle:/next', 'terminate:/next']); } - public function streamFailingAfterItsHeadIsOutIsLoggedAndEndsTheLoop(): void + public function streamFailingAfterItsHeadIsOutIsLoggedTerminatesAndKeepsServing(): void { $failing = FakeExchange::for('/stream'); + $next = FakeExchange::for('/next'); $failure = new \RuntimeException('stream failed'); - $kernel = new TestKernel(static fn(): Response => new StreamedResponse(static function () use ($failure): void { - echo 'partial'; - \ob_flush(); + $kernel = new TestKernel(static fn(Request $request): Response => $request->getPathInfo() === '/next' + ? new Response('healthy') + : new StreamedResponse(static function () use ($failure): void { + echo 'partial'; + \ob_flush(); - throw $failure; - })); + throw $failure; + })); - $host = $this->serve($kernel, new FakeHttpDispatcher($failing, FakeExchange::for('/must-not-run')), result: 1); + $host = $this->serve($kernel, new FakeHttpDispatcher($failing, $next)); + Assert::same($failing->status, 200); Assert::same($failing->getBody(), 'partial'); Assert::false($failing->isFinalized()); - Assert::same($kernel->events, ['handle:/stream']); + Assert::same($kernel->events, ['handle:/stream', 'terminate:/stream', 'handle:/next', 'terminate:/next']); + Assert::same($next->getBody(), 'healthy'); + Assert::true($next->isFinalized()); Assert::count($host->logs, 1); Assert::same($host->logs[0]['context']['exception'], $failure); } diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 0031afb..002fc40 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -181,6 +181,77 @@ public function requestMetadataOfTheBootIsNotInheritedAndTheListenerIsKept(): vo } } + public function applicationEnvironmentVariablesSurviveRequestMetadataFiltering(): void + { + $settings = ['SERVER_ROLE' => 'api', 'REQUEST_TIMEOUT' => '30', 'AUTH_TOKEN' => 'token', 'CONTENT_DIR' => '/content', 'REMOTE_STORAGE' => 's3', 'PHP_AUTH_SETTING' => 'custom']; + $_SERVER = $settings + $_SERVER; + + $request = $this->serveOne(FakeExchange::for('/env')); + + foreach ($settings as $key => $value) { + Assert::same($request->server->get($key), $value, $key); + } + } + + public function inputLimitsTruncateParametersWithoutInvokingTheDebugErrorHandler(): void + { + $limit = (int) \ini_get('max_input_vars'); + Assert::true($limit > 0); + $pairs = $fields = []; + for ($i = 0; $i <= $limit; ++$i) { + $pairs[] = 'p' . $i . '=value'; + $fields[] = new FormField('p' . $i, 'value', []); + } + $encoded = \implode('&', $pairs); + $exchanges = [ + FakeExchange::for('/query?' . $encoded), + FakeExchange::for('/form', 'POST', ['content-type' => ['application/x-www-form-urlencoded']], $encoded), + FakeExchange::for('/multipart', 'POST', body: new Multipart($fields, [])), + FakeExchange::for('/cookies', headers: ['cookie' => [\implode('; ', $pairs)]]), + ]; + $kernel = new TestKernel(); + $debugHandler = static function (int $severity, string $message): never { + throw new \ErrorException($message, severity: $severity); + }; + \set_error_handler($debugHandler); + try { + $this->serve($kernel, ...$exchanges); + // Read the current handler without emitting another warning. + $restored = \set_error_handler($debugHandler); + \restore_error_handler(); + } finally { + \restore_error_handler(); + } + + Assert::same($restored, $debugHandler); + Assert::count($kernel->requests, 4); + foreach ($exchanges as $exchange) { + Assert::same($exchange->status, 200); + Assert::true($exchange->isFinalized()); + } + $bags = [$kernel->requests[0]->query, $kernel->requests[1]->request, $kernel->requests[2]->request, $kernel->requests[3]->cookies]; + foreach ($bags as $bag) { + Assert::count($bag->all(), $limit); + Assert::same($bag->get('p' . ($limit - 1)), 'value'); + Assert::false($bag->has('p' . $limit)); + } + } + + public function overlyNestedCookieDoesNotTurnAParsingWarningIntoA500(): void + { + $name = 'nested' . \str_repeat('[child]', (int) \ini_get('max_input_nesting_level') + 1); + \set_error_handler(static function (int $severity, string $message): never { + throw new \ErrorException($message, severity: $severity); + }); + try { + $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => [$name . '=value; kept=yes']])); + } finally { + \restore_error_handler(); + } + + Assert::same($request->cookies->all(), ['kept' => 'yes']); + } + public function urlEncodedFormIsParsedIntoNestedFields(): void { $request = $this->serveOne(FakeExchange::for( @@ -328,7 +399,7 @@ public function multipartFieldsAndFilesKeepTheirNestingAndUploadsAreRemovedAfter 'name' => $file->getClientOriginalName(), 'content' => \file_get_contents($file->getPathname()), 'path' => $file->getPathname(), - 'empty' => $request->files->all()['empty']->getError(), + 'empty' => $request->files->all()['empty'], ]; return new Response(); @@ -345,7 +416,7 @@ public function multipartFieldsAndFilesKeepTheirNestingAndUploadsAreRemovedAfter Assert::same($seen['fields'], ['meta' => ['name' => 'Ada']]); Assert::same($seen['name'], 'note.txt'); Assert::same($seen['content'], 'upload-body'); - Assert::same($seen['empty'], \UPLOAD_ERR_NO_FILE); + Assert::null($seen['empty']); Assert::false(\is_file($seen['path'])); Assert::false(\is_file($source)); } @@ -432,7 +503,7 @@ public function multipartFileNamesAreMangledLikeFieldNames(): void $request = $kernel->requests[0]; Assert::same($request->request->all(), ['field_name' => 'value', 'list_one' => ['item']]); Assert::same(\array_keys($request->files->all()), ['file_name', 'list_two']); - Assert::true($request->files->all()['list_two'][0] instanceof UploadedFile); + Assert::same($request->files->all()['list_two'], []); } public function urlEncodedBodyIsParsedWhateverTheMethod(): void diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php index 23b3c9e..0747d70 100644 --- a/tests/Feature/DispatcherResponseTest.php +++ b/tests/Feature/DispatcherResponseTest.php @@ -11,6 +11,7 @@ use Rapira\Sdk\Testing\Double\Http\FakeExchange; use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; +use Rapira\Symfony\Tests\Support\PhpProcess; use Rapira\Symfony\Tests\Support\ReadCountingStream; use Rapira\Symfony\Tests\Support\ScriptedExchange; use Rapira\Symfony\Tests\Support\TestKernel; @@ -30,6 +31,7 @@ final class DispatcherResponseTest { use FakeRuntimeLifecycle; + use PhpProcess; #[AfterTest] public function unregisterStreamWrapper(): void @@ -454,55 +456,6 @@ private static function file(string $content): string return $path; } - /** - * @return array{int, string} The exit code and the combined output. - */ - private static function runPhpScript(string $script): array - { - $process = \proc_open( - [\PHP_BINARY, '-dxdebug.mode=off', $script], - [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], - $pipes, - ); - if (!\is_resource($process)) { - throw new \RuntimeException('Unable to start the PHP subprocess.'); - } - - \fclose($pipes[0]); - \stream_set_blocking($pipes[1], false); - \stream_set_blocking($pipes[2], false); - $output = ''; - $exitCode = null; - $deadline = \microtime(true) + 10; - try { - do { - $output .= \stream_get_contents($pipes[1]); - $output .= \stream_get_contents($pipes[2]); - $status = \proc_get_status($process); - if (!$status['running']) { - $exitCode = $status['exitcode']; - break; - } - if (\microtime(true) >= $deadline) { - throw new \RuntimeException('PHP subprocess timed out.'); - } - \usleep(10_000); - } while (true); - - $output .= \stream_get_contents($pipes[1]); - $output .= \stream_get_contents($pipes[2]); - } finally { - if (\proc_get_status($process)['running']) { - \proc_terminate($process); - } - \fclose($pipes[1]); - \fclose($pipes[2]); - $closeStatus = \proc_close($process); - } - - return [$exitCode === -1 ? $closeStatus : $exitCode, $output]; - } - /** * @param \Closure(): Response $respond */ diff --git a/tests/Feature/FileResponseCompatibilityTest.php b/tests/Feature/FileResponseCompatibilityTest.php new file mode 100644 index 0000000..628441e --- /dev/null +++ b/tests/Feature/FileResponseCompatibilityTest.php @@ -0,0 +1,30 @@ + 0, + 'status' => 200, + 'body' => 'file-body', + 'finalized' => true, + 'sentFiles' => [], + 'logs' => [], + ]); + } +} diff --git a/tests/Feature/WorkerModeTest.php b/tests/Feature/WorkerModeTest.php index 1500faf..9fcf65b 100644 --- a/tests/Feature/WorkerModeTest.php +++ b/tests/Feature/WorkerModeTest.php @@ -99,6 +99,20 @@ public function requestMetadataOfTheBootIsNotCarriedIntoRequests(): void Assert::same($request->server->get('SCRIPT_FILENAME'), '/app/public/index.php'); } + public function applicationEnvironmentVariablesSurviveRequestMetadataFiltering(): void + { + $settings = ['SERVER_ROLE' => 'api', 'REQUEST_TIMEOUT' => '30', 'AUTH_TOKEN' => 'token', 'CONTENT_DIR' => '/content', 'REMOTE_STORAGE' => 's3', 'PHP_AUTH_SETTING' => 'custom']; + $_SERVER = $settings + $_SERVER; + $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true))->queue('GET', '/env'); + $kernel = new TestKernel(); + + Assert::same($this->run($runtime, $kernel), 0); + + foreach ($settings as $key => $value) { + Assert::same($kernel->requests[0]->server->get($key), $value, $key); + } + } + public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): void { $runtime = (new FakeRuntime(Mode::Worker, captureOutput: true)) @@ -106,15 +120,15 @@ public function responseIsSentBeforeTheRequestTerminatesAndTheNextOneIsServed(): ->queue('GET', '/two') ->queue('GET', '/three'); $kernel = new TestKernel(terminate: static function () use ($runtime, &$kernel): void { - $kernel->events[] = \sprintf('sent:%d served:%d', \count($runtime->outputs), $runtime->servedRequests); + $kernel->events[] = \sprintf('finished:%d current:%s body:%s', $runtime->finishedRequests, $_SERVER['REQUEST_URI'], \ob_get_contents()); }); $this->run($runtime, $kernel); Assert::same($kernel->events, [ - 'handle:/one', 'terminate:/one', 'sent:1 served:1', - 'handle:/two', 'terminate:/two', 'sent:2 served:2', - 'handle:/three', 'terminate:/three', 'sent:3 served:3', + 'handle:/one', 'terminate:/one', 'finished:1 current:/one body:/one', + 'handle:/two', 'terminate:/two', 'finished:2 current:/two body:/two', + 'handle:/three', 'terminate:/three', 'finished:3 current:/three body:/three', ]); Assert::same($runtime->outputs, ['/one', '/two', '/three']); } diff --git a/tests/Fixtures/file-response-compatibility.php b/tests/Fixtures/file-response-compatibility.php new file mode 100644 index 0000000..770670a --- /dev/null +++ b/tests/Fixtures/file-response-compatibility.php @@ -0,0 +1,38 @@ + $severity === E_DEPRECATED); +try { + require \dirname(__DIR__, 2) . '/vendor/autoload.php'; +} finally { + \restore_error_handler(); +} + +\class_alias(FileResponseWithoutInternalState::class, BinaryFileResponse::class); +$response = new FileResponseWithoutInternalState('file-body', headers: ['Content-Type' => 'text/plain']); +$exchange = FakeExchange::for('/file'); +$host = (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher($exchange)))->install(); +$runtime = new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']); +$result = $runtime->getRunner(new TestKernel(static fn(): Response => $response))->run(); + +echo \json_encode([ + 'result' => $result, + 'status' => $exchange->status, + 'body' => $exchange->getBody(), + 'finalized' => $exchange->isFinalized(), + 'sentFiles' => $exchange->sentFiles, + 'logs' => $host->logs, +], \JSON_THROW_ON_ERROR); diff --git a/tests/Support/FileResponseWithoutInternalState.php b/tests/Support/FileResponseWithoutInternalState.php new file mode 100644 index 0000000..16e6893 --- /dev/null +++ b/tests/Support/FileResponseWithoutInternalState.php @@ -0,0 +1,20 @@ += $deadline) { + throw new \RuntimeException('PHP subprocess timed out.'); + } + \usleep(10_000); + } while (true); + + $output .= \stream_get_contents($pipes[1]); + $output .= \stream_get_contents($pipes[2]); + } finally { + if (\proc_get_status($process)['running']) { + \proc_terminate($process); + } + \fclose($pipes[1]); + \fclose($pipes[2]); + $closeStatus = \proc_close($process); + } + + return [$exitCode === -1 ? $closeStatus : $exitCode, $output]; + } +} From ed7098bc9016fe823b1700c563864c1dc9b91073 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 03:12:25 +0400 Subject: [PATCH 39/42] fix: log the warning of input truncated past max_input_vars in Dispatcher mode test: drop the nesting-level cookie test, which PHP never warns about docs: say that a cut-off Dispatcher response is failed only after terminate Classic and Worker modes log PHP's warning, so a silent truncation hid dropped input from operators. Assisted-By: Claude Opus 5.5 --- README.md | 4 ++-- src/Internal/DispatcherRequestFactory.php | 11 ++++++--- src/Internal/WorkerRunner.php | 2 +- tests/Feature/DispatcherRequestTest.php | 27 +++++++++-------------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ecbf46c..3fa15b5 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Code that reads `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER` or `php://i Uploaded files are renamed out of Rapira's spool, which the host empties once the response is sent, so they stay readable through `kernel->terminate()`; the Runtime removes them afterwards. A file the application moved stays where it was moved. Empty upload inputs are normalized by Symfony's `FileBag`, just as in Classic and Worker modes. -Input exceeding PHP's parsing limits (such as `max_input_vars`) is truncated as in PHP's startup parsing, including when Symfony's debug error handler is enabled. +Input past `max_input_vars` is truncated and PHP's warning is logged through `Rapira\log()`, as in PHP's startup parsing, also when Symfony's debug error handler is enabled. ## Dispatcher responses @@ -99,7 +99,7 @@ Responses are prepared by HttpFoundation, with every repeated header and `Set-Co - If the client leaves mid-stream, the rest of the output is dropped and terminate still runs. The streaming code is not told: `connection_aborted()` does not reflect the exchange. - `EventStreamResponse` is answered with `500`: it closes every output buffer after each event, so nothing is left to capture the next one. Use a `StreamedResponse` that calls `ob_flush()` after each event. -A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on: the kernel was not involved. A response that fails after its head is out cannot be answered any more; it is logged, Rapira fails the unfinished exchange, and termination runs before the worker serves the next request. A damaged output buffer stack still ends the loop because it cannot be reused safely. +A request that cannot be converted, and a response that fails before its head is written, such as a terminal `1xx` status or a header value the wire cannot carry, are logged through `Rapira\log()` and answered with `400` or `500`, and the worker goes on: the kernel was not involved. A response that fails after its head is out cannot be answered any more; it is logged, `kernel->terminate()` runs, and only then does Rapira fail the unfinished exchange, so the client waits on the cut-off body until terminate returns. The worker then serves the next request. A damaged output buffer stack still ends the loop because it cannot be reused safely. ## Persistent state diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php index 635581e..b147950 100644 --- a/src/Internal/DispatcherRequestFactory.php +++ b/src/Internal/DispatcherRequestFactory.php @@ -8,6 +8,7 @@ use Rapira\Http\Request as RapiraRequest; use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; +use Rapira\LogLevel; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -261,9 +262,13 @@ private static function parseQuery(string $query): array return []; } - // Match PHP's startup parsing: input beyond its limits is truncated, not turned into a - // server error by Symfony's debug error handler. Restore that handler even if parsing fails. - \set_error_handler(static fn(): bool => true, \E_WARNING); + // As PHP's startup parsing does, input past max_input_vars is truncated with a logged warning; Symfony's + // debug error handler would turn the warning into a 500. + \set_error_handler(static function (int $severity, string $message): bool { + \Rapira\log($message, LogLevel::Warning); + + return true; + }, \E_WARNING); try { \parse_str($query, $result); } finally { diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index 346cab4..e851cf4 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -60,7 +60,7 @@ public function run(): int /** * Serves one request, if the host has one, and terminates it. The request and the response die with - * this frame, so the cycle collection that follows can free them. + * the handler, so the cycle collection that follows can free them. * * @param array $boot * diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php index 002fc40..8813946 100644 --- a/tests/Feature/DispatcherRequestTest.php +++ b/tests/Feature/DispatcherRequestTest.php @@ -9,6 +9,7 @@ use Rapira\Http\Request as RapiraRequest; use Rapira\Http\UploadedFile as RapiraUploadedFile; use Rapira\InetAddress; +use Rapira\LogLevel; use Rapira\Mode; use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Sdk\Testing\Double\Http\FakeExchange; @@ -215,7 +216,7 @@ public function inputLimitsTruncateParametersWithoutInvokingTheDebugErrorHandler }; \set_error_handler($debugHandler); try { - $this->serve($kernel, ...$exchanges); + $host = $this->serve($kernel, ...$exchanges); // Read the current handler without emitting another warning. $restored = \set_error_handler($debugHandler); \restore_error_handler(); @@ -235,21 +236,11 @@ public function inputLimitsTruncateParametersWithoutInvokingTheDebugErrorHandler Assert::same($bag->get('p' . ($limit - 1)), 'value'); Assert::false($bag->has('p' . $limit)); } - } - - public function overlyNestedCookieDoesNotTurnAParsingWarningIntoA500(): void - { - $name = 'nested' . \str_repeat('[child]', (int) \ini_get('max_input_nesting_level') + 1); - \set_error_handler(static function (int $severity, string $message): never { - throw new \ErrorException($message, severity: $severity); - }); - try { - $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => [$name . '=value; kept=yes']])); - } finally { - \restore_error_handler(); + Assert::count($host->logs, 4); + foreach ($host->logs as $log) { + Assert::same($log['level'], LogLevel::Warning); + Assert::string($log['message'])->contains('Input variables exceeded'); } - - Assert::same($request->cookies->all(), ['kept' => 'yes']); } public function urlEncodedFormIsParsedIntoNestedFields(): void @@ -648,10 +639,12 @@ private function serveOne(FakeExchange $exchange): Request return $kernel->requests[0]; } - private function serve(TestKernel $kernel, FakeExchange ...$exchanges): void + private function serve(TestKernel $kernel, FakeExchange ...$exchanges): FakeRuntime { - (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install(); + $host = (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install(); self::runtime()->getRunner($kernel)->run(); + + return $host; } } From b864b22abbc0d314c6c0a9920840e6fb60f0a0b2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 03:37:16 +0400 Subject: [PATCH 40/42] fix: remove the pull request template file --- .github/PULL_REQUEST_TEMPLATE.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index cecccf6..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,6 +0,0 @@ -| Q | A -| ------------- | --- -| Is bugfix? | ✔️/❌ -| New feature? | ✔️/❌ -| Breaks BC? | ✔️/❌ -| Fixed issues | From 928ef54ec15c54af0a79b5f6e5c3d93b9ad9e3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Mon, 12 Oct 2026 14:08:04 +0300 Subject: [PATCH 41/42] fix: normalize trusted sendfile responses for Rapira --- README.md | 2 + src/Internal/ClassicRunner.php | 4 +- src/Internal/ExchangeResponseEmitter.php | 6 ++- src/Internal/FileResponse.php | 39 +++++++++++++++++++ src/Internal/SapiResponse.php | 10 ++++- src/Internal/WorkerRunner.php | 6 +-- tests/Acceptance/Support/ServerRequests.php | 43 +++++++++++++++++++++ tests/App/Kernel.php | 11 ++++++ tests/App/download.txt | 1 + tests/Feature/DispatcherResponseTest.php | 35 +++++++++++++++++ tests/Feature/RuntimeTest.php | 23 +++++++++++ 11 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/Internal/FileResponse.php create mode 100644 tests/App/download.txt diff --git a/README.md b/README.md index 3fa15b5..95a82d7 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ The same setup stays valid outside Rapira: under php-fpm or the CLI the Runtime Worker and Dispatcher serve only an `HttpKernelInterface` application and refuse anything else with a `LogicException`. Rapira owns recycling and `max_requests`; the Runtime adds no request limit of its own. +Rapira does not interpret reverse-proxy file headers such as `X-Sendfile` or `X-Accel-Redirect`. In all three modes, the Runtime removes those headers from `BinaryFileResponse` and prepares it without `X-Sendfile-Type`, even if the kernel already prepared it with that header trusted. Files are sent through PHP or Dispatcher's `Exchange::sendFile()`, with ranges and `HEAD` preserved. The application's request is unchanged, and Classic mode outside Rapira keeps the SAPI's usual sendfile behavior. + In Classic and Worker modes the response is sent inside an output buffer of its own, flushed every 8 KiB and on every `ob_flush()`, as under php-fpm with `output_buffering`. The host builds a Worker request's `$_SERVER` from the request alone, so the Runtime adds the boot-time variables back, `APP_ENV`, `APP_SECRET` and the rest of the environment, but never boot-time request metadata such as `HTTP_*`, `REMOTE_ADDR` or `HTTPS`. Application variables such as `SERVER_ROLE` and `REQUEST_TIMEOUT` are preserved. ## Failures diff --git a/src/Internal/ClassicRunner.php b/src/Internal/ClassicRunner.php index 4d2048f..4f1b02d 100644 --- a/src/Internal/ClassicRunner.php +++ b/src/Internal/ClassicRunner.php @@ -30,7 +30,9 @@ public function run(): int $request = Request::createFromGlobals(); $response = $this->kernel->handle($request); - SapiResponse::send($response); + // Outside Rapira this is a userland contract stub; preserve the actual SAPI's file offload. + $native = (new \ReflectionFunction('Rapira\get_mode'))->isInternal(); + SapiResponse::send($response, $native ? $request : null); // The client has its answer while terminate runs. Outside Rapira the stub answers false, and the // request is finished the way Symfony's own runner does it, which keeps it open in debug. if (!rapira_finish_request() && !$this->debug) { diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php index dcfbc17..9f8f11a 100644 --- a/src/Internal/ExchangeResponseEmitter.php +++ b/src/Internal/ExchangeResponseEmitter.php @@ -153,7 +153,11 @@ private function send(Request $request, Response $response, string $prefix): voi $request->setMethod('GET'); } - $response->prepare($request); + if ($response instanceof BinaryFileResponse) { + FileResponse::prepare($response, $request); + } else { + $response->prepare($request); + } // prepare() sets the length of the file before it knows the status that sends none of it. if ($response instanceof BinaryFileResponse && !$response->isSuccessful()) { $response->headers->remove('Content-Length'); diff --git a/src/Internal/FileResponse.php b/src/Internal/FileResponse.php new file mode 100644 index 0000000..567ba2a --- /dev/null +++ b/src/Internal/FileResponse.php @@ -0,0 +1,39 @@ +headers->get('X-Sendfile-Type'); + if ($type !== null && $type !== '') { + // Older Symfony releases also accept arbitrary sendfile header names. + $file = $response->getFile(); + $path = $file->getRealPath(); + if ($response->headers->get($type) === ($path === false ? $file->getPathname() : $path)) { + $response->headers->remove($type); + } + } + foreach (['X-Sendfile', 'X-Accel-Redirect', 'X-LIGHTTPD-send-file'] as $header) { + $response->headers->remove($header); + } + + $request = clone $request; + $request->headers->remove('X-Sendfile-Type'); + // Rebuild the body length and range state suppressed by the earlier prepare(). + $response->prepare($request); + } +} diff --git a/src/Internal/SapiResponse.php b/src/Internal/SapiResponse.php index 4d466a2..9ae8987 100644 --- a/src/Internal/SapiResponse.php +++ b/src/Internal/SapiResponse.php @@ -4,6 +4,8 @@ namespace Rapira\Symfony\Internal; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; /** @@ -22,9 +24,15 @@ final class SapiResponse * A streamed callback commonly calls `ob_flush()`, which needs an open buffer: php-fpm has one from * `output_buffering`, the Rapira SAPI starts without. Without it `ob_flush()` raises a notice that * Symfony's error handler turns into an exception halfway through the body. + * + * A request enables Rapira's file preparation; null keeps a different SAPI's sendfile support. */ - public static function send(Response $response): void + public static function send(Response $response, ?Request $request): void { + if ($request !== null && $response instanceof BinaryFileResponse) { + FileResponse::prepare($response, $request); + } + $level = \ob_get_level(); \ob_start(null, self::CHUNK_SIZE); diff --git a/src/Internal/WorkerRunner.php b/src/Internal/WorkerRunner.php index e851cf4..c9f8d58 100644 --- a/src/Internal/WorkerRunner.php +++ b/src/Internal/WorkerRunner.php @@ -88,7 +88,7 @@ private function serveNext(array $boot): bool return true; } - SapiResponse::send($response); + SapiResponse::send($response, $request); rapira_finish_request(); // Keep the host's timeout and uploaded files alive until termination is complete. @@ -117,10 +117,10 @@ private function answerFailure(\Throwable $exception, Request $request): void { try { if (!\headers_sent()) { - SapiResponse::send(($this->failureResponse)($exception, $request)); + SapiResponse::send(($this->failureResponse)($exception, $request), $request); } } catch (\Throwable) { - \headers_sent() or SapiResponse::send(new Response('Internal Server Error', 500, ['Content-Type' => 'text/plain; charset=UTF-8'])); + \headers_sent() or SapiResponse::send(new Response('Internal Server Error', 500, ['Content-Type' => 'text/plain; charset=UTF-8']), $request); } } } diff --git a/tests/Acceptance/Support/ServerRequests.php b/tests/Acceptance/Support/ServerRequests.php index 8d489a4..9474011 100644 --- a/tests/Acceptance/Support/ServerRequests.php +++ b/tests/Acceptance/Support/ServerRequests.php @@ -29,6 +29,49 @@ public function processRunsInTheRequestedMode(): void Assert::same($body, $this->mode()->value); } + public function trustedSendfileHeadersDoNotSuppressDownloadsOrLeakPaths(): void + { + $directory = \dirname(__DIR__, 2) . \DIRECTORY_SEPARATOR . 'App' . \DIRECTORY_SEPARATOR; + foreach (['X-Sendfile', 'X-Accel-Redirect', 'X-LIGHTTPD-send-file', 'X-Custom-Sendfile'] as $type) { + foreach (['GET', 'range', 'HEAD'] as $method) { + $headers = []; + [$status, $body] = $this->request('/file', [ + \CURLOPT_HTTPHEADER => [ + 'X-Sendfile-Type: ' . $type, + 'X-Accel-Mapping: ' . $directory . '=/protected/', + ...($method === 'range' ? ['Range: bytes=2-5'] : []), + ], + \CURLOPT_NOBODY => $method === 'HEAD', + \CURLOPT_HEADERFUNCTION => static function (\CurlHandle $curl, string $line) use (&$headers): int { + if (\str_contains($line, ':')) { + [$name, $value] = \explode(':', $line, 2); + $headers[\strtolower($name)] = \trim($value); + } + + return \strlen($line); + }, + ]); + + Assert::same($status, $method === 'range' ? 206 : 200); + Assert::same($body, match ($method) { + 'HEAD' => '', + 'range' => '2345', + default => "0123456789\n", + }); + // The host omits Content-Length from HEAD responses on the wire. + if ($method !== 'HEAD') { + Assert::same($headers['content-length'], $method === 'range' ? '4' : '11'); + } + foreach (['x-sendfile', 'x-accel-redirect', 'x-lighttpd-send-file', 'x-custom-sendfile'] as $header) { + Assert::false(isset($headers[$header])); + } + if ($method === 'range') { + Assert::same($headers['content-range'], 'bytes 2-5/11'); + } + } + } + } + abstract protected function mode(): Mode; /** diff --git a/tests/App/Kernel.php b/tests/App/Kernel.php index 05a7f07..d26ec47 100644 --- a/tests/App/Kernel.php +++ b/tests/App/Kernel.php @@ -4,6 +4,7 @@ namespace Rapira\Symfony\Tests\App; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,6 +39,7 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c '/' => new Response('OK'), '/mode' => new Response(\strtolower(\Rapira\get_mode()->name)), '/boot' => new Response($this->bootId), + '/file' => $this->fileResponse($request), '/upload' => new Response('uploaded'), '/termination' => new JsonResponse($this->lastTermination), '/terminate-timeout' => $this->timedResponse(), @@ -82,6 +84,15 @@ public function terminate(Request $request, Response $response): void } } + private function fileResponse(Request $request): Response + { + BinaryFileResponse::trustXSendfileTypeHeader(); + + // HttpKernel's response listener prepares the response before the Runtime sees it. + return (new BinaryFileResponse(__DIR__ . '/download.txt', headers: ['Content-Type' => 'text/plain'])) + ->prepare($request); + } + private function timedResponse(): Response { \set_time_limit(1); diff --git a/tests/App/download.txt b/tests/App/download.txt new file mode 100644 index 0000000..11f11f9 --- /dev/null +++ b/tests/App/download.txt @@ -0,0 +1 @@ +0123456789 diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php index 0747d70..20659db 100644 --- a/tests/Feature/DispatcherResponseTest.php +++ b/tests/Feature/DispatcherResponseTest.php @@ -375,6 +375,41 @@ public function fileTheHostRefusesIsStreamedUnderTheSameHead(): void Assert::true($exchange->isFinalized()); } + public function preparedSendfileResponsesUseRapiraOrPhpWithoutChangingTheRequest(): void + { + $path = self::file('0123456789'); + $trust = new \ReflectionProperty(BinaryFileResponse::class, 'trustXSendfileTypeHeader'); + $previous = $trust->getValue(); + BinaryFileResponse::trustXSendfileTypeHeader(); + + try { + foreach ([false, true] as $refuseFiles) { + $exchange = FakeExchange::for('/file', headers: [ + 'x-sendfile-type' => ['X-Sendfile'], + 'range' => ['bytes=2-5'], + ]); + $exchange->refuseFiles = $refuseFiles; + $kernel = new TestKernel(static fn(Request $request): Response => (new BinaryFileResponse( + $path, + headers: ['Content-Type' => 'text/plain', 'X-Application' => 'kept'], + ))->prepare($request)); + + Assert::same($this->serveWith($kernel, $exchange), 0); + + Assert::same($exchange->status, 206); + Assert::same(self::deliveredBody($exchange), '2345'); + Assert::null($exchange->header('x-sendfile')); + Assert::same($exchange->header('x-application'), 'kept'); + Assert::count($exchange->sentFiles, $refuseFiles ? 0 : 1); + Assert::true($exchange->isFinalized()); + Assert::same($kernel->requests[0]->headers->get('X-Sendfile-Type'), 'X-Sendfile'); + } + } finally { + $trust->setValue(null, $previous); + @\unlink($path); + } + } + public function fileDeletedAfterSendIsStreamedAndThenDeleted(): void { $path = self::file('0123456789'); diff --git a/tests/Feature/RuntimeTest.php b/tests/Feature/RuntimeTest.php index b9fef65..75e6733 100644 --- a/tests/Feature/RuntimeTest.php +++ b/tests/Feature/RuntimeTest.php @@ -11,6 +11,8 @@ use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Runtime; use Rapira\Symfony\Tests\Support\TestKernel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -94,6 +96,27 @@ public function classicModeOutsideRapiraServesTheRequestAsWell(): void Assert::same($kernel->events, ['handle:/classic', 'terminate:/classic']); } + public function classicModeOutsideRapiraPreservesTrustedSendfileResponses(): void + { + FakeRuntime::reset(); + $_SERVER['HTTP_X_SENDFILE_TYPE'] = 'X-Sendfile'; + $trust = new \ReflectionProperty(BinaryFileResponse::class, 'trustXSendfileTypeHeader'); + $previous = $trust->getValue(); + BinaryFileResponse::trustXSendfileTypeHeader(); + $response = new BinaryFileResponse(__FILE__, headers: ['Content-Type' => 'text/plain']); + $kernel = new TestKernel(static fn(Request $request): Response => $response->prepare($request)); + + try { + [$result, $output] = self::serveClassic(self::runtime(), $kernel); + + Assert::same($result, 0); + Assert::same($output, ''); + Assert::same($response->headers->get('X-Sendfile'), __FILE__); + } finally { + $trust->setValue(null, $previous); + } + } + public function nonHttpApplicationIsRefusedInWorkerMode(): never { (new FakeRuntime(Mode::Worker))->install(); From afa1521e34a2c2250334d1ba8fed1506a5f06321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zigmas=20Satkevi=C4=8Dius?= Date: Mon, 12 Oct 2026 15:03:19 +0300 Subject: [PATCH 42/42] style: satisfy updated Spiral code style rules --- tests/Acceptance/Support/ServerRequests.php | 2 +- tests/Feature/RuntimeTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Acceptance/Support/ServerRequests.php b/tests/Acceptance/Support/ServerRequests.php index 9474011..f4c2d2f 100644 --- a/tests/Acceptance/Support/ServerRequests.php +++ b/tests/Acceptance/Support/ServerRequests.php @@ -9,7 +9,7 @@ /** * The requests every mode must answer identically. A test case picks the mode with `RunRapira` and - * names it in {@see mode()}. Each case listens on its own `ADDRESS`, so a server that outlived the + * names it in {@see self::mode()}. Each case listens on its own `ADDRESS`, so a server that outlived the * previous case can never answer for the current one. */ trait ServerRequests diff --git a/tests/Feature/RuntimeTest.php b/tests/Feature/RuntimeTest.php index 75e6733..ec55d0c 100644 --- a/tests/Feature/RuntimeTest.php +++ b/tests/Feature/RuntimeTest.php @@ -8,8 +8,8 @@ use Rapira\Sdk\Testing\Double\FakeRuntime; use Rapira\Sdk\Testing\Double\Http\FakeExchange; use Rapira\Sdk\Testing\Double\Http\FakeHttpDispatcher; -use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Runtime; +use Rapira\Symfony\Tests\Support\FakeRuntimeLifecycle; use Rapira\Symfony\Tests\Support\TestKernel; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request;