diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..187eb38
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,24 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.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/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/.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
new file mode 100644
index 0000000..744c473
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,80 @@
+---
+
+name: ๐งน Quality
+
+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
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: ๐ ๏ธ Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ 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
+
+ - name: ๐จ Check code style
+ run: composer cs:diff
+
+ - name: ๐ Run static analysis
+ run: composer psalm:ci
+
+ 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
new file mode 100644
index 0000000..0de21de
--- /dev/null
+++ b/.github/workflows/testing.yml
@@ -0,0 +1,78 @@
+---
+
+name: ๐งช Testing
+
+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:
+ 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:
+ - os: ubuntu-latest
+ php-version: '8.4'
+ dependencies: lowest
+ 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 }}
+ extensions: curl, mbstring
+ 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 }}
+
+ # 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 }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a7f8480
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/.*/
+!/.github/
+/runtime/
+/vendor/
+/composer.lock
+/.env
+*.log
+/coverage.xml
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..12f2572
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,13 @@
+include(__DIR__ . '/src')
+ ->include(__DIR__ . '/tests')
+ ->include(__DIR__ . '/testo.php')
+ ->include(__DIR__ . '/composer-dependency-analyser.php')
+ ->include(__FILE__)
+ ->build();
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/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..2ad43cf
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,30 @@
+# BSD 3-Clause
+
+Copyright (c) 2026, Rapira.
+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..95a82d7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,123 @@
+# rapira/symfony
+
+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
+
+```shell
+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.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
+
+Select the Runtime in the application's `composer.json` and run `composer dump-autoload`:
+
+```json
+{
+ "extra": {
+ "runtime": {
+ "class": "Rapira\\Symfony\\Runtime"
+ }
+ }
+}
+```
+
+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 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.
+
+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
+
+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.
+
+## 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 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()`.
+
+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 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
+
+Responses are prepared by HttpFoundation, with every repeated header and `Set-Cookie` value kept.
+
+- 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. 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, `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
+
+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/composer-dependency-analyser.php b/composer-dependency-analyser.php
new file mode 100644
index 0000000..5b4c026
--- /dev/null
+++ b/composer-dependency-analyser.php
@@ -0,0 +1,11 @@
+disableComposerAutoloadPathScan()
+ ->setFileExtensions(['php'])
+ ->addPathToScan(__DIR__ . '/src', isDev: false)
+ ->addPathToScan(__DIR__ . '/tests', isDev: true);
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..fa53544
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,88 @@
+{
+ "name": "rapira/symfony",
+ "description": "Symfony Runtime for Rapira Worker and Dispatcher modes",
+ "license": "BSD-3-Clause",
+ "type": "library",
+ "keywords": [
+ "dispatcher",
+ "rapira",
+ "runtime",
+ "symfony",
+ "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",
+ "rapira/contract": "^0.9",
+ "symfony/http-foundation": "^7.4 || ^8.0",
+ "symfony/http-kernel": "^7.4 || ^8.0",
+ "symfony/runtime": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "ext-curl": "*",
+ "fidry/cpu-core-counter": "^1.2",
+ "llm/skills": "^1.14",
+ "rapira/testing": "^0.1.7",
+ "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",
+ "testo/test": "^0.1.8",
+ "testo/testo": "^0.10.55",
+ "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-1.x": "0.1.x-dev"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "llm/skills": true,
+ "symfony/runtime": true
+ },
+ "sort-packages": true
+ },
+ "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:feature": "testo --suite=Feature",
+ "test:acceptance": "testo --suite=Acceptance"
+ }
+}
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/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
+}
diff --git a/src/Internal/BootServer.php b/src/Internal/BootServer.php
new file mode 100644
index 0000000..5be5d57
--- /dev/null
+++ b/src/Internal/BootServer.php
@@ -0,0 +1,99 @@
+ $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, bool $script): array
+ {
+ $variables = [];
+ /** @psalm-suppress MixedAssignment */
+ foreach ($server as $key => $value) {
+ if (\is_string($key) && self::isBootVariable($key, $script)) {
+ $variables[$key] = $value;
+ }
+ }
+
+ return $variables;
+ }
+
+ /**
+ * @psalm-pure
+ */
+ private static function isBootVariable(string $key, bool $script): bool
+ {
+ if (\in_array($key, self::SCRIPT_KEYS, true)) {
+ return $script;
+ }
+ if (\in_array($key, self::REQUEST_KEYS, true)) {
+ return false;
+ }
+
+ return !\str_starts_with($key, 'HTTP_');
+ }
+}
diff --git a/src/Internal/ClassicRunner.php b/src/Internal/ClassicRunner.php
new file mode 100644
index 0000000..4f1b02d
--- /dev/null
+++ b/src/Internal/ClassicRunner.php
@@ -0,0 +1,49 @@
+kernel->handle($request);
+
+ // 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) {
+ \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/DispatcherRequest.php b/src/Internal/DispatcherRequest.php
new file mode 100644
index 0000000..1be44a0
--- /dev/null
+++ b/src/Internal/DispatcherRequest.php
@@ -0,0 +1,43 @@
+ $uploads
+ */
+ public function __construct(
+ public readonly Request $request,
+ private array $uploads,
+ ) {}
+
+ /**
+ * 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 ($paths as $path) {
+ \is_file($path) and @\unlink($path);
+ }
+ }
+
+ public function cleanup(): void
+ {
+ self::removeFiles($this->uploads);
+ $this->uploads = [];
+ }
+}
diff --git a/src/Internal/DispatcherRequestFactory.php b/src/Internal/DispatcherRequestFactory.php
new file mode 100644
index 0000000..b147950
--- /dev/null
+++ b/src/Internal/DispatcherRequestFactory.php
@@ -0,0 +1,342 @@
+ */
+ private array $boot;
+
+ public function __construct()
+ {
+ $this->boot = BootServer::variables($_SERVER, script: false);
+ }
+
+ /**
+ * @throws \RuntimeException An upload could not be taken over from the host.
+ */
+ public function create(RapiraRequest $source): DispatcherRequest
+ {
+ $headers = self::mergeHeaders($source->headers);
+ $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->body, $headers);
+ $files = [];
+ $content = $source->body;
+ }
+
+ $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) {
+ DispatcherRequest::removeFiles($uploads);
+
+ throw $exception;
+ }
+
+ // 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);
+ }
+
+ 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
+ *
+ * @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
+ {
+ $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];
+ } else {
+ $merged[$lower] = [$name, $values];
+ }
+ }
+
+ return $merged;
+ }
+
+ /**
+ * @param array}> $headers
+ *
+ * @return array
+ */
+ private static function parseForm(string $body, array $headers): array
+ {
+ // 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 [];
+ }
+
+ return self::parseQuery($body);
+ }
+
+ /**
+ * @return 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[] = \rawurlencode($field->name) . '=' . \rawurlencode($field->value);
+ }
+
+ return self::parseQuery(\implode('&', $pairs));
+ }
+
+ /**
+ * @param list $uploads Receives the path of every file taken over, also when this throws.
+ *
+ * @return 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 = [];
+ foreach ($multipart->files as $index => $file) {
+ if (!self::isUploadName($file->name)) {
+ continue;
+ }
+ $pairs[] = \rawurlencode($file->name) . '=' . $index;
+ $files[$index] = self::createFile($file, $uploads);
+ }
+
+ $tree = self::parseQuery(\implode('&', $pairs));
+ \array_walk_recursive($tree, static function (mixed &$value) use ($files): void {
+ $value = $files[(int) $value];
+ });
+
+ return $tree;
+ }
+
+ /**
+ * @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|array
+ {
+ if ($file->clientFilename === '') {
+ // 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
+ // 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;
+
+ if (!@\rename($file->tmpPath, $path)) {
+ throw new \RuntimeException(\sprintf('Unable to keep Rapira upload "%s".', $file->tmpPath));
+ }
+
+ return new UploadedFile($path, $file->clientFilename, $file->clientMediaType, \UPLOAD_ERR_OK, true);
+ }
+
+ /**
+ * 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) {
+ [$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.
+ $probe = self::parseQuery(\rawurlencode($name));
+ $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;
+ }
+ $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));
+ }
+
+ /**
+ * @return array
+ */
+ private static function parseQuery(string $query): array
+ {
+ if ($query === '') {
+ return [];
+ }
+
+ // 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 {
+ \restore_error_handler();
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array}> $headers
+ *
+ * @return array
+ *
+ * @psalm-mutation-free
+ */
+ 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 ($https) {
+ $params['HTTPS'] = 'on';
+ }
+
+ if ($request->remote instanceof InetAddress) {
+ $params['REMOTE_ADDR'] = $request->remote->ip;
+ $params['REMOTE_PORT'] = $request->remote->port;
+ } 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) {
+ $params['SERVER_ADDR'] = $request->server->ip;
+ $params['SERVER_PORT'] = $request->server->port;
+ }
+
+ // 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]) {
+ $key = \strtoupper(\str_replace('-', '_', $lower));
+ $value = \implode($lower === 'cookie' ? '; ' : ', ', $values);
+ $params['HTTP_' . $key] = $value;
+ if ($key === 'CONTENT_TYPE' || $key === 'CONTENT_LENGTH') {
+ $params[$key] = $value;
+ }
+ }
+
+ if ($request->authority !== null) {
+ $params['HTTP_HOST'] = $request->authority;
+ }
+
+ return $params + $this->boot;
+ }
+}
diff --git a/src/Internal/DispatcherRunner.php b/src/Internal/DispatcherRunner.php
new file mode 100644
index 0000000..429324b
--- /dev/null
+++ b/src/Internal/DispatcherRunner.php
@@ -0,0 +1,173 @@
+requestFactory = new DispatcherRequestFactory();
+ }
+
+ #[\Override]
+ public function run(): int
+ {
+ $this->kernel instanceof KernelInterface and $this->kernel->boot();
+
+ while (true) {
+ try {
+ $exchange = $this->dispatcher->receive();
+ } catch (ClosedException) {
+ return 0;
+ }
+
+ 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
+ // application; make it configurable, see https://github.com/rapira-rs/sdk-php/issues/14
+ \gc_collect_cycles();
+ }
+ }
+
+ /**
+ * 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]);
+ }
+
+ /**
+ * 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
+ * the output buffer stack.
+ */
+ 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);
+
+ 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 {
+ $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) {
+ } 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) {
+ $this->kernel->terminate($request, $response);
+ }
+ } finally {
+ $converted->cleanup();
+ }
+ }
+}
diff --git a/src/Internal/ExchangeResponseEmitter.php b/src/Internal/ExchangeResponseEmitter.php
new file mode 100644
index 0000000..9f8f11a
--- /dev/null
+++ b/src/Internal/ExchangeResponseEmitter.php
@@ -0,0 +1,360 @@
+send($request, $response, $prefix);
+ } 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) {
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * @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, bool $keepLength = true): array
+ {
+ $headers = [];
+ /**
+ * @var non-empty-string $name
+ * @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;
+ }
+ }
+
+ foreach ($response->headers->getCookies() as $cookie) {
+ $headers['Set-Cookie'][] = (string) $cookie;
+ }
+
+ return $headers;
+ }
+
+ private function send(Request $request, Response $response, string $prefix): 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.');
+ }
+
+ // 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');
+ }
+
+ 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');
+ }
+
+ /** @var int<100, 599> $status */
+ $status = $response->getStatusCode();
+ $headers = self::headers($response);
+
+ 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('');
+
+ return;
+ }
+
+ if ($response instanceof BinaryFileResponse && $this->sendFile($response, $status, $headers)) {
+ return;
+ }
+
+ $content = $response->getContent();
+ if ($content !== false) {
+ $this->writeBuffered($status, self::headers($response, keepLength: false), $prefix . $content);
+
+ return;
+ }
+
+ $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($prefix . $chunk, false);
+ $prefix = '';
+ });
+ $this->headWritten or $this->writeHead($status, $headers);
+ $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));
+ }
+
+ /**
+ * 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 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 */
+ $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;
+ }
+
+ $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;
+ }
+
+ /**
+ * 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(static function (string $chunk, int $phase) use ($forward, &$failure): string {
+ if ($forward === null || $failure !== null || $chunk === '' || ($phase & \PHP_OUTPUT_HANDLER_CLEAN) !== 0) {
+ return '';
+ }
+
+ try {
+ $forward($chunk);
+ } catch (\Throwable $exception) {
+ $failure = $exception;
+ }
+
+ return '';
+ }, self::CHUNK_SIZE);
+ $ownedLevel = $baseLevel + 1;
+
+ try {
+ $response->sendContent();
+ $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 = self::firstFailure($failure, $exception);
+ } finally {
+ try {
+ $this->restoreBufferLevel($baseLevel);
+ } catch (OutputBufferException $exception) {
+ $failure = self::firstFailure($failure, $exception);
+ }
+ }
+
+ $failure === null or throw $failure;
+ }
+
+ private function closeBuffersAbove(int $targetLevel): void
+ {
+ while (\ob_get_level() > $targetLevel) {
+ @\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) {
+ @\ob_end_clean() or throw new OutputBufferException('The output buffer stack cannot be restored safely.');
+ }
+
+ \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(int $status, array $headers): void
+ {
+ try {
+ $this->exchange->writeHead($status, $headers);
+ } catch (WorkDiscardedException $exception) {
+ throw new ResponseDiscardedException($exception);
+ }
+
+ $this->headWritten = true;
+ }
+
+ private function writeBody(string $content, bool $eos = true): void
+ {
+ try {
+ $this->exchange->writeBody($content, $eos);
+ } catch (WorkDiscardedException $exception) {
+ throw new ResponseDiscardedException($exception);
+ }
+ }
+}
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/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 @@
+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/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 @@
+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;
+ }
+
+ /**
+ * Serves one request, if the host has one, and terminates it. The request and the response die with
+ * the handler, so the cycle collection that follows can free them.
+ *
+ * @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
+ {
+ /** @var \Throwable|null $failure */
+ $failure = null;
+
+ $served = \Rapira\handle_request(function () use ($boot, &$failure): bool {
+ $_SERVER += $boot;
+
+ $request = Request::createFromGlobals();
+ 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, $request);
+ 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;
+
+ 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), $request);
+ }
+ } catch (\Throwable) {
+ \headers_sent() or SapiResponse::send(new Response('Internal Server Error', 500, ['Content-Type' => 'text/plain; charset=UTF-8']), $request);
+ }
+ }
+}
diff --git a/src/Runtime.php b/src/Runtime.php
new file mode 100644
index 0000000..5b1c69e
--- /dev/null
+++ b/src/Runtime.php
@@ -0,0 +1,101 @@
+mode = \Rapira\get_mode();
+
+ if ($this->mode !== Mode::Classic) {
+ $_SERVER['APP_RUNTIME_MODE'] = 'web=1&worker=1';
+ }
+
+ parent::__construct($options);
+ }
+
+ #[\Override]
+ 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);
+ }
+
+ return match ($this->mode) {
+ Mode::Classic => new ClassicRunner($application, (bool) ($this->options['debug'] ?? false)),
+ 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();
+ $dispatcher instanceof HttpDispatcher or throw new \LogicException(\sprintf(
+ 'Rapira Dispatcher mode requires an HTTP dispatcher; got %s.',
+ $dispatcher::class,
+ ));
+
+ return $dispatcher;
+ }
+}
diff --git a/testo.php b/testo.php
new file mode 100644
index 0000000..74aab27
--- /dev/null
+++ b/testo.php
@@ -0,0 +1,29 @@
+assertFlushingStreamArrivesWhole(sameWorker: false);
+ }
+
+ protected function mode(): Mode
+ {
+ return Mode::Classic;
+ }
+}
diff --git a/tests/Acceptance/DispatcherModeTest.php b/tests/Acceptance/DispatcherModeTest.php
new file mode 100644
index 0000000..549400e
--- /dev/null
+++ b/tests/Acceptance/DispatcherModeTest.php
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 0000000..f4c2d2f
--- /dev/null
+++ b/tests/Acceptance/Support/ServerRequests.php
@@ -0,0 +1,115 @@
+request('/');
+
+ Assert::same($status, 200);
+ Assert::same($body, 'OK');
+ }
+
+ public function processRunsInTheRequestedMode(): void
+ {
+ [, $body] = $this->request('/mode');
+
+ 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;
+
+ /**
+ * 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);
+ }
+
+ /**
+ * @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 $options = []): array
+ {
+ $curl = \curl_init();
+ \curl_setopt_array($curl, $options + [
+ \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..9ae16ba
--- /dev/null
+++ b/tests/Acceptance/WorkerModeTest.php
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 0000000..d26ec47
--- /dev/null
+++ b/tests/App/Kernel.php
@@ -0,0 +1,102 @@
+
+ */
+ private array $lastTermination = [];
+
+ 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),
+ '/file' => $this->fileResponse($request),
+ '/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();
+ \flush();
+ echo 'two';
+ }),
+ 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 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);
+
+ return new Response($this->bootId);
+ }
+}
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/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..7a3534a
--- /dev/null
+++ b/tests/App/rapira.toml
@@ -0,0 +1,9 @@
+[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"
+# One process, so consecutive requests reach the same worker.
+processes = 1
diff --git a/tests/Feature/DispatcherModeTest.php b/tests/Feature/DispatcherModeTest.php
new file mode 100644
index 0000000..426be19
--- /dev/null
+++ b/tests/Feature/DispatcherModeTest.php
@@ -0,0 +1,364 @@
+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 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'),
+ );
+
+ $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 {
+ self::runtime()->getRunner($kernel)->run();
+ } finally {
+ Assert::same($dispatcher->receives, 0);
+ }
+ }
+
+ public function kernelFailureIsAnsweredWithA500LoggedAndEndsTheLoop(): void
+ {
+ $failing = FakeExchange::for('/fail');
+ $next = FakeExchange::for('/must-not-run');
+ $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'));
+
+ $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
+ {
+ $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');
+ }
+
+ 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());
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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 streamFailingAfterItsHeadIsOutIsLoggedTerminatesAndKeepsServing(): void
+ {
+ $failing = FakeExchange::for('/stream');
+ $next = FakeExchange::for('/next');
+ $failure = new \RuntimeException('stream failed');
+ $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;
+ }));
+
+ $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', '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);
+ }
+
+ /**
+ * @param FakeExchange $answer What $failing records the answer in.
+ */
+ private function assertAnsweredWith500AndTheNextServed(Exchange $failing, FakeExchange $answer, TestKernel $kernel): void
+ {
+ $next = FakeExchange::for('/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);
+ }
+
+ /**
+ * @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($failurePage)->getRunner($kernel)->run(), $result);
+
+ return $host;
+ }
+}
diff --git a/tests/Feature/DispatcherRequestTest.php b/tests/Feature/DispatcherRequestTest.php
new file mode 100644
index 0000000..8813946
--- /dev/null
+++ b/tests/Feature/DispatcherRequestTest.php
@@ -0,0 +1,650 @@
+ 'kept',
+ 'SCRIPT_FILENAME' => '/app/public/index.php',
+ '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',
+ ] + $_SERVER;
+ $_GET = ['global' => 'unchanged'];
+ $_POST = ['global' => 'unchanged'];
+ $_COOKIE = ['global' => 'unchanged'];
+ $_FILES = ['global' => 'unchanged'];
+ $exchange = new FakeExchange(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'],
+ 'Content-Length' => ['20'],
+ ],
+ body: '{"binary":"\u0000"}',
+ ));
+
+ [$request, $globals] = $this->serveWithGlobals($exchange);
+
+ Assert::same($request->getMethod(), '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);
+ 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::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');
+ 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"}');
+ Assert::same($request->server->get('APP_ENV'), 'test');
+ Assert::same($request->server->get('APP_CUSTOM_SETTING'), 'kept');
+ 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 = [
+ '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',
+ ] + $_SERVER;
+ $kernel = new TestKernel();
+
+ $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),
+ )), 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');
+ Assert::same($internet->server->get('APP_ENV'), 'test');
+ Assert::same($internet->server->get('REQUEST_SCHEME'), 'http');
+ foreach (['AUTH_TYPE', 'CONTENT_LENGTH', 'CONTENT_TYPE', 'HTTPS', 'SERVER_NAME'] as $key) {
+ Assert::false($internet->server->has($key), $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 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 {
+ $host = $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));
+ }
+ Assert::count($host->logs, 4);
+ foreach ($host->logs as $log) {
+ Assert::same($log['level'], LogLevel::Warning);
+ Assert::string($log['message'])->contains('Input variables exceeded');
+ }
+ }
+
+ public function urlEncodedFormIsParsedIntoNestedFields(): void
+ {
+ $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'],
+ ]);
+ }
+
+ 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 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 authorityNeverBecomesTheServerName(): void
+ {
+ $request = $this->serveOne(new FakeExchange(self::request(uri: 'https://[::1]:8443/', target: '/')));
+
+ Assert::false($request->server->has('SERVER_NAME'));
+ 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');
+ $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'],
+ ];
+
+ 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),
+ ],
+ )));
+
+ Assert::same($seen['fields'], ['meta' => ['name' => 'Ada']]);
+ Assert::same($seen['name'], 'note.txt');
+ Assert::same($seen['content'], 'upload-body');
+ Assert::null($seen['empty']);
+ Assert::false(\is_file($seen['path']));
+ Assert::false(\is_file($source));
+ }
+
+ /**
+ * Holds whether the failure stops the loop or is answered.
+ */
+ public function unreadableUploadLeavesNoTemporaryCopies(): void
+ {
+ $source = self::file('first');
+ $before = self::uploadCopies();
+ $missing = \sys_get_temp_dir() . '/rapira-missing-' . \bin2hex(\random_bytes(8));
+ $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();
+
+ try {
+ $this->serve($kernel, $exchange);
+ } catch (\RuntimeException $exception) {
+ Assert::same($exception->getMessage(), \sprintf('Unable to keep Rapira upload "%s".', $missing));
+ } finally {
+ @\unlink($source);
+ }
+
+ Assert::same($kernel->requests, []);
+ Assert::same(self::uploadCopies(), $before);
+ }
+
+ public function uploadMovedByTheApplicationSurvivesTheCleanup(): void
+ {
+ $source = self::file('keep');
+ $destination = \tempnam(\sys_get_temp_dir(), 'rapira-moved-');
+ @\unlink($destination);
+ $kernel = new TestKernel(static function (Request $request) use ($destination): Response {
+ $request->files->get('file')->move(\dirname($destination), \basename($destination));
+
+ 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);
+ }
+
+ /**
+ * 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::same($request->files->all()['list_two'], []);
+ }
+
+ public function urlEncodedBodyIsParsedWhateverTheMethod(): 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('/', 'QUERY', $form, 'a=3'),
+ FakeExchange::for('/', 'patch', $form, 'a=4'),
+ FakeExchange::for('/', 'GET', ['content-type' => ['text/plain']], 'a=5'),
+ );
+
+ 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
+ {
+ $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']);
+ }
+
+ public function firstOfDuplicateCookieNamesWins(): void
+ {
+ $request = $this->serveOne(FakeExchange::for('/', headers: ['cookie' => ['a=1; a=2', 'a=3']]));
+
+ Assert::same($request->cookies->get('a'), '1');
+ }
+
+ 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');
+ }
+
+ 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
+ * @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 = '',
+ InetAddress|UnixAddress|null $remote = null,
+ InetAddress|UnixAddress|null $server = null,
+ ): RapiraRequest {
+ return new RapiraRequest(
+ $method,
+ $uri,
+ $target,
+ null,
+ $protocol,
+ $headers,
+ $body,
+ $remote ?? new InetAddress('127.0.0.1', 40000),
+ $server ?? new InetAddress('127.0.0.1', 8080),
+ null,
+ 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): FakeRuntime
+ {
+ $host = (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install();
+
+ self::runtime()->getRunner($kernel)->run();
+
+ return $host;
+ }
+}
diff --git a/tests/Feature/DispatcherResponseTest.php b/tests/Feature/DispatcherResponseTest.php
new file mode 100644
index 0000000..20659db
--- /dev/null
+++ b/tests/Feature/DispatcherResponseTest.php
@@ -0,0 +1,508 @@
+ ['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 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();
+
+ Assert::same($this->serveWith($kernel, $buffered, $streamed), 0);
+
+ 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 {
+ Assert::same($this->serveWith($kernel, $exchange), 0);
+ } finally {
+ @\unlink($path);
+ }
+
+ Assert::same($exchange->headers['Content-Length'], ['10']);
+ Assert::same($exchange->chunks, []);
+ Assert::count($exchange->sentFiles, 1);
+ }
+
+ public function outputOfAFailedHandleIsDroppedAndTheBufferRestored(): void
+ {
+ $kernel = new TestKernel(static function (): never {
+ echo 'partial';
+ \ob_start();
+ echo 'nested';
+
+ throw new \RuntimeException('kernel failed');
+ });
+ $level = \ob_get_level();
+
+ \ob_start();
+ try {
+ $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
+ {
+ $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 {
+ Assert::same($this->serveWith($kernel, $gone, $next), 0);
+ 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';
+ }));
+ $result = (new Runtime(['debug' => false, 'error_handler' => false, 'env' => 'test']))->getRunner($kernel)->run();
+ fwrite(STDERR, $result . ': ' . $host->logs[0]['context']['exception']->getMessage());
+ PHP);
+
+ try {
+ [$status, $output] = self::runPhpScript($script);
+ } finally {
+ @\unlink($script);
+ }
+
+ Assert::same($status, 0);
+ Assert::string($output)->contains('1: 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 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) {
+ $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']]);
+
+ // 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->sentFiles, [['path' => $realPath, 'offset' => 2, 'length' => 4]]);
+ Assert::true($range->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 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');
+ $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'], ['']);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * @param \Closure(): Response $respond
+ */
+ private function serve(\Closure $respond, FakeExchange ...$exchanges): void
+ {
+ Assert::same($this->serveWith(new TestKernel($respond), ...$exchanges), 0);
+ }
+
+ private function serveWith(TestKernel $kernel, Exchange ...$exchanges): int
+ {
+ (new FakeRuntime(Mode::Dispatcher, new FakeHttpDispatcher(...$exchanges)))->install();
+
+ return self::runtime()->getRunner($kernel)->run();
+ }
+}
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/RuntimeTest.php b/tests/Feature/RuntimeTest.php
new file mode 100644
index 0000000..ec55d0c
--- /dev/null
+++ b/tests/Feature/RuntimeTest.php
@@ -0,0 +1,180 @@
+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 classicModeServesOneRequestFromTheSuperglobalsAndFinishesItBeforeTerminating(): void
+ {
+ unset($_SERVER['APP_RUNTIME_MODE']);
+ $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']));
+
+ [$result, $output, $levelChange] = self::serveClassic($runtime, $kernel);
+
+ 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');
+ 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();
+
+ 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);
+ }
+
+ /**
+ * 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
new file mode 100644
index 0000000..9fcf65b
--- /dev/null
+++ b/tests/Feature/WorkerModeTest.php
@@ -0,0 +1,296 @@
+ '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 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 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))
+ ->queue('GET', '/one')
+ ->queue('GET', '/two')
+ ->queue('GET', '/three');
+ $kernel = new TestKernel(terminate: static function () use ($runtime, &$kernel): void {
+ $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', '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']);
+ }
+
+ 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))
+ ->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 kernelBootsBeforeTheFirstRequest(): void
+ {
+ $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('container broken');
+ try {
+ $this->run($runtime, $kernel);
+ } finally {
+ Assert::same($runtime->servedRequests, 0);
+ }
+ }
+
+ 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'));
+
+ $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);
+ }
+
+ /**
+ * @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'),
+ ];
+ }
+
+ /**
+ * @param null|\Closure(\Throwable, Request): Response $failurePage
+ */
+ private function run(FakeRuntime $runtime, HttpKernelInterface $kernel, ?\Closure $failurePage = null): int
+ {
+ $runtime->install();
+
+ return self::runtime($failurePage)->getRunner($kernel)->run();
+ }
+}
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/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
new file mode 100644
index 0000000..a5356c4
--- /dev/null
+++ b/tests/Support/FakeRuntimeLifecycle.php
@@ -0,0 +1,52 @@
+> */
+ 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.
+ *
+ * @param null|\Closure(\Throwable, Request): Response $failurePage Builds a {@see FailurePageRuntime} that
+ * answers a failure escaping the kernel with it.
+ */
+ private static function runtime(?\Closure $failurePage = null): Runtime
+ {
+ $options = ['debug' => false, 'error_handler' => false, 'env' => 'test'];
+
+ return $failurePage === null
+ ? new Runtime($options)
+ : new FailurePageRuntime($options + ['failure_page' => $failurePage]);
+ }
+}
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];
+ }
+}
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 . '://'));
+ }
+}
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/TestKernel.php b/tests/Support/TestKernel.php
new file mode 100644
index 0000000..6deb366
--- /dev/null
+++ b/tests/Support/TestKernel.php
@@ -0,0 +1,52 @@
+ 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);
+ }
+}