Skip to content

feat: add Symfony Runtime for Rapira - #1

Merged
Zylius merged 42 commits into
1.xfrom
feat/symfony-runtime
Oct 12, 2026
Merged

Zylius merged 42 commits into
1.xfrom
feat/symfony-runtime

Conversation

@Zylius

@Zylius Zylius commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🔍 What was changed

The first version of rapira/symfony: a Symfony Runtime (Rapira\Symfony\Runtime) that serves a Symfony HTTP kernel from the stock public/index.php in every Rapira mode. Outside Rapira it behaves as Symfony's own runtime.

How it works

  • Classic boots the kernel per request, sends the response, calls rapira_finish_request(), then runs terminate().
  • Worker boots the kernel once, before the loop, and serves Rapira\handle_request() from the superglobals; terminate() runs after the client has its answer but inside the request, so uploads and max_execution_time still apply.
  • Dispatcher converts each Rapira\Http\Exchange to a Symfony Request and writes the response straight into the exchange, one exchange at a time; files go out through Exchange::sendFile(), streamed bodies are forwarded as they are printed.

A failure that escapes the kernel is answered with a plain 500 (overridable through Runtime::createFailureResponse()), logged through Rapira\log(), and ends the loop so Rapira starts a fresh kernel. A kernel that cannot boot fails the worker before it takes a request, so Rapira counts it as a failed start.

Why?

Symfony apps on Rapira need a runtime that keeps the kernel resident in Worker and Dispatcher modes and maps Rapira's request and response model onto HttpFoundation without the application changing.

Review notes

  • The REMOTE_ADDR placeholder in trusted_proxies resolves at boot, when $_SERVER holds no client, so it trusts no proxy in Worker and Dispatcher modes; the README says to list proxies or use PRIVATE_SUBNETS.
  • Dispatcher mode needs Rapira 0.9.1+: the x86_64 Linux builds of 0.9.0 crash a worker that checks whether its exchange is cancelled.
  • A worker that fails on every request is not counted as failing by the host; filed as [🐛 BUG]: A worker that fails on every request is never flagged unhealthy rapira#209.

Checklist

  • How was this tested:
    • Feature tests against Rapira's fake runtime for all three modes
    • Acceptance tests against a live Rapira binary in Classic, Worker and Dispatcher modes
    • CI green on Ubuntu and Windows, PHP 8.4 and 8.5, lowest and highest dependencies

Documentation

The README covers installation, setup, mode behaviour, failure handling, the Dispatcher request and response mapping, and what Dispatcher mode does not support (superglobals, header(), EventStreamResponse).

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: dffdbf1b-817d-420b-93eb-d82071a73976

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@roxblnfk
roxblnfk requested a review from a team October 6, 2026 15:51
@FluffyDiscord

Copy link
Copy Markdown

@Zylius "Testo"? could we use standard Phpunit?

@FluffyDiscord

Copy link
Copy Markdown

fine by me I would say, once some of the things mentioned are addressed. How it is now, its basically what the plain runtime should be doing. However I will not be able to be this as a base for symfony bundle, as its too barebone, but once again, thats the point of symfony runtime, which this fulfills

Comment thread src/WorkerRunner.php Resolved
Comment thread src/Runtime.php Resolved
Comment thread src/DispatcherRunner.php Resolved
Comment thread src/DispatcherRunner.php Resolved
@roxblnfk

roxblnfk commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Status update: the infrastructure and the test suite are reworked; fixes in src/ are still in progress, so this is not ready to merge yet.

Done

  • CI, release-please, CODEOWNERS, .gitattributes and the dependency analyser now match the sibling bridges; tests run on Linux and Windows × PHP 8.4/8.5 plus a lowest-dependencies job.
  • The tests drive the bridge only through Runtime → getRunner() → run(), with the host scripted by the rapira/testing doubles, so the test seams in src/ can be removed without touching the tests.
  • An acceptance suite serves a fixture app through a live Rapira in Classic, Worker and Dispatcher modes.

Findings pinned by tests

Each one is a test with the expected behaviour, marked #[Skip('known bug: …')] until the fix lands; every one was run unskipped once to confirm it fails for the stated reason.

  • Worker and Classic: a StreamedResponse that calls ob_flush() is cut after the first chunk. The Rapira SAPI starts without output_buffering and Response::send() closes every buffer under a non-CLI SAPI, so ob_flush() raises a notice that the error handler turns into an exception; the client gets the first chunk followed by the error page. Only visible on a live server (acceptance).
  • Dispatcher cookies are not parsed the way PHP does: values are not URL-decoded, the last duplicate wins instead of the first, and several Cookie headers are joined with , instead of ; . A cookie set by Symfony itself does not round-trip.
  • Dispatcher: a HEAD request to a BinaryFileResponse reads the whole file before discarding it.
  • Dispatcher: any failure converting the request or emitting the response kills the worker without answering the client: an unreadable upload, EventStreamResponse, a terminal 1xx status, a header value the host rejects. The sibling bridges answer 4xx/5xx and keep serving.
  • Dispatcher: a urlencoded form is not parsed when the Content-Type is not lowercase.
  • Worker: the request served by the last handle_request() call is not terminated when the host returns false together with it. The real host returns false from a separate empty call, so this is latent; the rapira/testing double is being aligned with the host separately.

Still in progress

  • Fixing the findings above in src/ and unskipping their tests.
  • Building the Symfony Request directly from the exchange, dropping nyholm/psr7 and symfony/psr-http-message-bridge.
  • Removing the test seams (Runtime::getMode()/getHttpDispatcher(), RequestLoop).
  • rapira_finish_request() in Classic mode, uploads moved with rename() instead of copied, the behaviour for non-HttpKernel applications in resident modes, README corrections.

@roxblnfk
roxblnfk force-pushed the feat/symfony-runtime branch from 19b296b to c6c60c4 Compare October 6, 2026 22:07
@roxblnfk

roxblnfk commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Status update: the findings from the review are fixed in src/, every test pinned as a known bug now passes, and CI is green on Linux and Windows.

Done

  • Worker and Classic send the response inside an own output buffer, so a StreamedResponse that calls ob_flush() is no longer cut after the first chunk. Classic mode uses an own runner everywhere: rapira_finish_request() releases the client before terminate(), and outside Rapira the stub answers false and the runner finishes the request the way Symfony's does.
  • The Symfony Request is built directly from the exchange. nyholm/psr7 and symfony/psr-http-message-bridge are gone. Request parsing matches PHP and the Rapira SAPI, as rapira/http 0.1.5 does:
    • cookies are decoded and the first duplicate wins;
    • a form body is parsed by its Content-Type alone, case-insensitively, for any method, QUERY included;
    • file names are mangled as in $_FILES;
    • the query is taken from the raw request-target;
    • uploads are kept with rename() instead of a copy.
  • Dispatcher responses:
    • a HEAD request no longer reads the file;
    • BinaryFileResponse goes through Exchange::sendFile();
    • streams are forwarded in 8 KiB chunks and on every ob_flush();
    • handler output echoed outside the body is sent before it;
    • a long buffered body is split.
  • Dispatcher resilience: a failure before the head is written is answered with 500, logged, and the loop goes on.
  • Cleanup:
    • the test seams are removed from Runtime, and the runners live in Internal\;
    • a non-HttpKernelInterface application is refused in resident modes;
    • SCRIPT_* no longer reaches Dispatcher requests, since Rapira has no document root;
    • the README describes all of this.

Found

  • [🐛 BUG]: Segfault in execute_ex after Exchange::isCancelled() in dispatcher mode (Linux 0.9.0) rapira#201. On Linux, Rapira 0.9.0 crashes a Dispatcher worker (SIGSEGV in libphp.so) when Exchange::isCancelled() or isFinalized() is called inside a method. Whether it fires depends on code layout, which is why the Linux acceptance run started failing after unrelated commits. The last commit works around it:

    • serve() no longer checks isCancelled(), so an exchange cancelled while queued is handled and its response dropped on the first write;
    • emitError() tracks the head itself instead of asking isFinalized();
    • cancelledExchangeIsSkipped is skipped.

    Both sites carry a TODO, and the removal is tracked in Remove the rapira-rs/rapira#201 workaround from Dispatcher mode #2. The yii runner calls isCancelled() in the same way and is currently unaffected only by luck of layout.

  • Header spoofing: Symfony's HeaderBag turns _ into -, so a field named X_Forwarded_For would pose as X-Forwarded-For, not only through HTTP_*. Field names outside [A-Za-z0-9-] are now dropped from both, as Rapira's HTTP plugin does in Worker mode.

  • Global state that remains in Dispatcher mode makes Symfony serve exchanges one at a time, as the README now says:

    • output buffers around the stream and around handle();
    • the boot-time $_SERVER environment read once;
    • APP_RUNTIME_MODE written once;
    • inside Symfony itself: ini_set('default_mimetype') in prepare(), the static Request configuration, and native sessions.

    Code that needs superglobals belongs in Worker mode.

Not done

@roxblnfk

roxblnfk commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

@FluffyDiscord replying here to your comment on a457931, so it does not get lost with the rebased commits:

@roxblnfk you are doing way too much for symfony runtime, these changes are meant to be in a symfony bundle, and some of them look suspiciously like the ones i have already done.

https://github.com/php-runtime/roadrunner-symfony-nyholm this is "the plain" and simple runtime. Thats what this needs to be. We can do another repo, the bundle itself, that will have full Rapira support

Agreed on the split: features belong in a bundle, this package should be the runtime. I looked at FluffyDiscord/rapira-symfony-bundle, and most of what it does — worker events, kernel reboot and debug error pages, warmers, profiling, Doctrine, Sentry, DI configuration — has no place here and should stay there.

Where I'd draw the line differently is "thin". roadrunner-symfony-nyholm is small because psr-http-message-bridge converts the request and RoadRunner's PSR-7 worker writes the response. Rapira's Dispatcher mode has no such layer, so converting the exchange and writing the response has to live somewhere, and today it lives twice: here and in the bundle. Most of the code in this PR is that conversion done right, not features:

  • all three modes, with Classic and Worker fixed under the Rapira SAPI (an own buffer for ob_flush(), rapira_finish_request() before terminate(), boot-time env);
  • the request parsed the way PHP fills the superglobals: cookies, forms by Content-Type, $_FILES names, raw query, spoofed header names dropped;
  • the response prepared by Symfony and written to the exchange, streamed, files through sendFile();
  • a failure before the head answered with 500 instead of killing the worker.

The plan:

  • The runtime stays here. This repository can become a monorepo with the runtime split out into rapira/symfony-runtime, so further packages can be added here later without getting in its way.
  • The bundle gets lighter. It drops its own receive loop, request factory and response writers and builds on the runtime, keeping all its features. Where it needs to hook in (error handling and kernel reboot, lifecycle events, logging), the runtime offers a few extension points that it looks up in the container after boot.
  • Some things here move towards the bundle or become runtime options: capturing output echoed outside the response, the per-request gc_collect_cycles() (Configurable memory cleanup instead of gc_collect_cycles() after every request sdk-php#14), and logging failures somewhere richer than Rapira\log().

What do you think? If this works for you, I'll list the extension points the bundle would need, and we can agree on them before either side changes.

@FluffyDiscord

Copy link
Copy Markdown

@roxblnfk compare the http worker loop, I cannot use this runtime at all - and I should not.

I am overwriting how kernel restarts, I have exception handlers for each situation (out of date now for rapira 0.9.0, or 0.9.1 if that found issue is fixed l, will need to update that to bring code in line, the idea stays), I dispatch events, log things, reset services, warmup stuff, and all of this need specific steps to take/hook into in the worker loop, which plain runtime should not simply offer.

Not sure how extension points would be feasible here, not to bloat the runtime, remember, people might not want that bundle, and might want to do all of it themselves

@roxblnfk

roxblnfk commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

@FluffyDiscord good point about the worker loop, that makes sense.

What I'd still like to share is not the loop but the two pieces at its edges, which have no lifecycle at all:

  • Exchange → Symfony Request: server bag, headers, cookies, query, form and multipart bodies, uploads.
  • Symfony Response → exchange: head, plain and streamed bodies, files through sendFile(), HEAD and empty responses.

If rapira/symfony makes these public, your HttpWorker can use them in place of its own factory and writers, and fixes reach both packages — the issues I opened on the bundle are all in these two pieces.

Your Runtime could also extend Rapira\Symfony\Runtime and override only the Dispatcher runner, getting Classic and Worker modes with their Rapira fixes for free.

Would that work for you? If not, two independent implementations is fine too.

@Zylius

Zylius commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Great review guys. Thanks for your input. One more thing I don't like about this whole PR is that to run this either the composer.json requires

{
    "extra": {
        "runtime": {
            "class": "Rapira\\Symfony\\Runtime"
        }
    }
} 

Or you need to specify it while launching the command. When switching around servers for PHP this couples the actual source files (composer.json) to the server used, or it has a cumbersome launch command. http.env entry in rapira config file would really help

@FluffyDiscord

Copy link
Copy Markdown

@FluffyDiscord good point about the worker loop, that makes sense.

What I'd still like to share is not the loop but the two pieces at its edges, which have no lifecycle at all:

  • Exchange → Symfony Request: server bag, headers, cookies, query, form and multipart bodies, uploads.
  • Symfony Response → exchange: head, plain and streamed bodies, files through sendFile(), HEAD and empty responses.

If rapira/symfony makes these public, your HttpWorker can use them in place of its own factory and writers, and fixes reach both packages — the issues I opened on the bundle are all in these two pieces.

Your Runtime could also extend Rapira\Symfony\Runtime and override only the Dispatcher runner, getting Classic and Worker modes with their Rapira fixes for free.

Would that work for you? If not, two independent implementations is fine too.

Let me think about this a bit. The runtime override is off the table though, too different.

@FluffyDiscord FluffyDiscord left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roxblnfk except the error renderer, its fine as is I guess. Static calls are against how symfony works, but here whatever, the explore query is spotty I would say and other remarks are just suggestions

Comment thread src/Internal/ErrorResponse.php Resolved
Comment thread src/Internal/WorkerRunner.php Resolved
@roxblnfk
roxblnfk force-pushed the feat/symfony-runtime branch from 9ef2bd6 to 3c3fc8a Compare October 7, 2026 19:09

@roxblnfk roxblnfk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-ups from two independent reviews of the current state, to be addressed in this PR. Each was checked against the code; the first one also on a live Rapira.

Comment thread src/Internal/WorkerRunner.php Resolved
Comment thread src/Internal/BootServer.php Resolved
@Zylius

Zylius commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@Zylius "Testo"? could we use standard Phpunit?

rapira/testing uses testo 🤔

@roxblnfk

roxblnfk commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Testo is OK guys

@roxblnfk
roxblnfk force-pushed the feat/symfony-runtime branch 3 times, most recently from 0899c51 to 3895c77 Compare October 9, 2026 21:15
@roxblnfk
roxblnfk requested a balanced review from Copilot October 10, 2026 00:02
@roxblnfk roxblnfk changed the title Feat/symfony runtime feat: add Symfony Runtime for Rapira Oct 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Trusted X-Sendfile handling can expose local paths and produce empty file responses across all runtime modes.

2 open findings
What changed in this PR

Introduces the initial rapira/symfony runtime, integrating Symfony HTTP kernels with Rapira’s Classic, Worker, and Dispatcher execution modes.

Changes:

  • Adds mode-specific runners and request/response translation.
  • Adds comprehensive feature and live-server acceptance tests.
  • Adds package documentation, CI, analysis, and release configuration.
File Description
src/​Runtime.php Selects the runner for each Rapira mode.
src/​Internal/​BootServer.php Filters boot-time server variables.
src/​Internal/​ClassicRunner.php Serves Classic-mode requests.
src/​Internal/​DispatcherRequest.php Owns converted requests and uploads.
src/​Internal/​DispatcherRequestFactory.php Converts exchanges into Symfony requests.
src/​Internal/​DispatcherRunner.php Implements the Dispatcher loop.
src/​Internal/​ExchangeResponseEmitter.php Writes Symfony responses to exchanges.
src/​Internal/​OutputBufferException.php Represents buffer-stack corruption.
src/​Internal/​ResponseDiscardedException.php Represents discarded responses.
src/​Internal/​SapiResponse.php Sends SAPI responses with buffering.
src/​Internal/​UnsentResponseException.php Wraps pre-head response failures.
src/​Internal/​WorkerRunner.php Implements the Worker loop.
tests/​Support/​TestKernel.php Provides a lifecycle-recording kernel.
tests/​Support/​ScriptedExchange.php Provides a configurable exchange double.
tests/​Support/​ReadCountingStream.php Detects unintended file reads.
tests/​Support/​PhpProcess.php Runs bounded PHP subprocesses.
tests/​Support/​ForeignDispatcher.php Models a non-HTTP dispatcher.
tests/​Support/​FileResponseWithoutInternalState.php Models future file-response compatibility.
tests/​Support/​FakeRuntimeLifecycle.php Resets fake-runtime process state.
tests/​Support/​FailurePageRuntime.php Tests custom failure responses.
tests/​Support/​BootRecordingKernel.php Records kernel boot ordering.
tests/​Fixtures/​file-response-compatibility.php Exercises file-response fallback behavior.
tests/​Feature/​WorkerModeTest.php Tests Worker lifecycle and failures.
tests/​Feature/​RuntimeTest.php Tests runtime mode selection.
tests/​Feature/​FileResponseCompatibilityTest.php Tests future Symfony compatibility.
tests/​Feature/​DispatcherResponseTest.php Tests Dispatcher response emission.
tests/​Feature/​DispatcherRequestTest.php Tests Dispatcher request conversion.
tests/​Feature/​DispatcherModeTest.php Tests Dispatcher lifecycle and failures.
tests/​App/​rapira.toml Configures the acceptance fixture server.
tests/​App/​public/​index.php Defines the fixture entrypoint.
tests/​App/​Kernel.php Implements the acceptance fixture kernel.
tests/​Acceptance/​WorkerModeTest.php Tests live Worker mode.
tests/​Acceptance/​Support/​ServerRequests.php Shares live HTTP assertions.
tests/​Acceptance/​DispatcherModeTest.php Tests live Dispatcher mode.
tests/​Acceptance/​ClassicModeTest.php Tests live Classic mode.
testo.php Configures feature and acceptance suites.
README.md Documents installation and runtime behavior.
composer.json Defines package metadata and dependencies.
composer-dependency-analyser.php Configures dependency analysis.
psalm.xml Configures static analysis.
.php-cs-fixer.dist.php Configures code style.
skills.json Configures skill discovery.
LICENSE.md Adds the BSD-3-Clause license.
CHANGELOG.md Initializes the changelog.
.github/​workflows/​testing.yml Runs the test matrix.
.github/​workflows/​security.yml Audits dependency security.
.github/​workflows/​quality.yml Runs style and static checks.
.github/​workflows/​create-release.yml Automates release creation.
.github/​release-please/​version.json Initializes release versions.
.github/​release-please/​config.json Configures release notes and versions.
.github/​ISSUE_TEMPLATE.md Adds an issue-report template.
.github/​dependabot.yml Configures dependency updates.
.github/​CODEOWNERS Assigns repository ownership.
.gitignore Excludes generated artifacts.
.gitattributes Configures archives and line endings.
.editorconfig Defines editor formatting defaults.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Internal/SapiResponse.php Resolved
roxblnfk and others added 25 commits October 12, 2026 22:07
Under the Rapira SAPI a worker starts without an output buffer, so ob_flush() inside a StreamedResponse callback raises a notice that Symfony's error handler turns into an exception, and the client gets the first chunk followed by an error page. Under the cli SAPI of the Feature suite the same callback finds a buffer and passes, so only a live server shows it. Worker and Classic modes are skipped as a known bug; Dispatcher mode buffers the callback itself and stays active.

The pool runs a single process so the request after the stream reaches the same worker, which the boot id route then confirms.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: require rapira/testing ^0.1.7

The last request went unterminated only because the 0.1.6 double returned false together with it; like the host, 0.1.7 answers true for every served request and false from a separate empty call.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The protected getMode() and getHttpDispatcher() hooks, the RequestLoop interface pair and the injectable boot $_SERVER of the request factory existed only so tests could replace the host; the Feature suite now scripts the host through the rapira/contract stubs instead, so the Runtime calls the Rapira functions directly and the runners build their own collaborators. The runners are internal and move into Internal\ next to them, which leaves Runtime the only public class. The options docblock no longer lists worker_loop_max, which only FrankenPHP's runner reads.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The Rapira SAPI starts a request without an output buffer, so ob_flush() in a StreamedResponse callback raised a notice that Symfony turned into an exception, and the client got the first chunk followed by an error page. Both modes now send the response inside a buffer of their own, with the 8 KiB chunk size output_buffering gives php-fpm, and close whatever buffers the body left open.

Classic mode went through Symfony's HttpKernelRunner, which knows fastcgi_finish_request() but not rapira_finish_request(), so the client waited for terminate(). It gets a runner of its own that finishes the request before terminating, as Worker mode now does too. Outside Rapira the contract stub answers false and the runner finishes the request the way Symfony's own does, so php-fpm serving the same application needs no separate path.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop kept the last request and response in its own variables while gc_collect_cycles() ran, so every request's graph survived until the next one replaced it. Serving a request now happens in a method of its own whose frame owns them. Terminate still runs after handle_request() returns: the response has been finished by then, and a terminate failure ends the worker instead of reaching the host as a failure of a request it already answered.

The host builds $_SERVER from the request alone, so the boot-time variables Dotenv set, APP_ENV and APP_SECRET among them, are still added to every request. Only HTTP_* entries were filtered out of them, so a boot-time HTTPS, QUERY_STRING or REMOTE_ADDR leaked into requests that lacked one; the worker now applies the same filter the dispatcher does.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Such an application went through Symfony's own runners, which run it once and return, so a resident worker exited after its first pass without ever serving a request. Getting the runner now fails with a LogicException that names the mode and the application type; Classic mode still hands it to Symfony.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: drop nyholm/psr7 and symfony/psr-http-message-bridge

The exchange went through a PSR-7 request on its way to HttpFoundation, and each hop lost a part of PHP's semantics: cookie values reached the application still URL-encoded, so a cookie Symfony set never read back the same; a repeated cookie name kept the last value where PHP keeps the first; several Cookie headers were joined with a comma; a form whose content type was not lowercase stayed unparsed; and file field names were nested by a parser of their own, without the mangling PHP applies to the fields next to them. The Symfony request is now built directly, with cookies, query, form and multipart names all parsed by parse_str() as in the laravel bridge, and form bodies parsed only for POST, PUT, PATCH and DELETE.

The host deletes an upload's spool file once the exchange finalizes, which is before terminate(), so each file used to be copied. It is now renamed instead, which the contract names as the way to keep it, and still removed after terminate().

The raw method travelled in three places, a request attribute and two server keys, for the emitter to tell a wire HEAD from what Symfony's uppercasing getMethod() calls HEAD. The emitter now reads it from the exchange it writes to, and the application sees it where PHP puts it, in REQUEST_METHOD.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
A HEAD request for a BinaryFileResponse was prepared as a GET, so the whole file was read only for the host to drop it; the response is now prepared for the request as it is, which leaves the file unread and still lets deleteFileAfterSend() remove it. A file is handed to the host with Exchange::sendFile(), ranges as an offset and a length, so PHP no longer holds its bytes. A file deleted after sending, a temporary file, a stream wrapper path and a status that sends no file still go through PHP, as does a file the host refuses.

Printed output was forwarded one byte at a time. It is now forwarded every 8 KiB and on every ob_flush(); a bare flush() reaches no output handler, so a body that relies on it alone waits for the chunk to fill, as under php-fpm with output_buffering. Output removed with ob_clean() is no longer forwarded, and the head now goes out with the first chunk instead of before the body runs. Header values of null are sent empty instead of reaching the host as null.

The range test removed its file before reading back the slice handed to the host, so it could only pass while nothing was handed over.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
A request that could not be converted, a terminal 1xx status, an EventStreamResponse, a header value the host rejects or a body that failed before printing anything propagated out of the loop, so one bad request cost the worker and every exchange queued behind it. Until a response head is written another response can still take its place, so such a failure is now logged through Rapira\log() and answered with a plain-text error, 400 when Symfony classifies the conversion failure as a bad request and 500 otherwise, and the loop goes on; a handled response that failed still terminates. A failure of the kernel, of terminate(), of a response whose head is already out, or of the output buffer stack still propagates, since the worker cannot vouch for its state after them.

Each exchange is served in a method of its own, so neither it nor the request outlives the call into the next receive().

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
… work

The README described Classic mode as Symfony's runner, dispatcher uploads as copies, streaming without a chunk size and sendFile() as unused, and told readers to install a branch alias that is no longer needed. It also ruled out native sessions in Dispatcher mode, while Symfony's session listener takes the session id from the request's cookies and sets the cookie on the response, so NativeSessionStorage works there; only a direct session_start() does not.

Symfony's Kernel::boot() resolves the REMOTE_ADDR placeholder of trusted_proxies once, from the $_SERVER of the moment, which in a resident worker is the first request's client; the README now warns against it.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK's dispatcher request factory was checked against php -S and the Rapira SAPI source, and the bridge differed from it in several places. A header field named outside [A-Za-z0-9-] reached the server bag, with `.` turned into `_`, and the header bag, which reads `_` as `-`; the host drops such a field in Worker mode, and here X_Forwarded_For could pose as X-Forwarded-For. It is now dropped as well.

Cookie values were URL-decoded with `+` as a space, a pair without `=` was skipped and a duplicate was detected before name mangling; PHP decodes cookies raw, keeps a bare name with an empty value and keeps the first of two names that mangle to one key. A form media type was cut only at a space or `;`, not at `,`. Form bodies were parsed only for POST, PUT, PATCH and DELETE, while the contract reads a body by its framing and never by the method name; a url-encoded or multipart body is now parsed by its Content-Type alone, for QUERY, GET or any other method. PHP drops an upload whose name has broken brackets, `u[v` or `a[b]c` say, while it repairs a text field of the same name; the bridge nested it.

The server params now carry what the SAPI sets in Worker mode and were missing here: REQUEST_SCHEME, GATEWAY_INTERFACE, SERVER_SOFTWARE, DOCUMENT_URI, and HTTP_CONTENT_TYPE and HTTP_CONTENT_LENGTH next to the CONTENT_* pair. The test that pinned REQUEST_SCHEME as absent was checking that the boot-time value does not leak, so it now expects the request's own scheme.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
What the application printed while handling a request, a var_dump() or a stray echo, went to the SAPI output of the dispatcher process, which reaches no client. It is now captured around handle() and sent ahead of the body, as a SAPI would and as the laravel bridge does; a response that carries a printed prefix drops its Content-Length, which no longer counts the body, and a file keeps its length and goes out without the prefix. The output of a handle() that throws is dropped with its buffers.

A buffered body went to the host in one write whatever its size. Above 8 MiB it is now written in slices under a Content-Length of its own, so HTTP/1.1 stays off chunked encoding. A buffered body also no longer carries the response's own Content-Length, which the host would enforce against a body it measures itself.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapira serves an application from the root, with no document root and no deployment in a subfolder, yet the dispatcher request carried SCRIPT_NAME, SCRIPT_FILENAME, PHP_SELF and the rest of the script location from the boot-time $_SERVER. Symfony's base URL detection reads them, so a path starting with /index.php lost that prefix to the base URL. Without them Symfony finds an empty base URL and base path, the whole path becomes the path info, and generated URLs are root-relative. The environment, APP_ENV and APP_SECRET among it, is still carried over; Worker mode keeps the script location, which the host fills there itself.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatcher mode is designed for code that serves exchanges concurrently, and the README did not say that Symfony cannot: a StreamedResponse is captured through process-wide output buffers and the framework keeps request-related state in statics and shared services. It now says so and sends code that needs the superglobals or header() to Worker mode. The request and response sections follow the latest changes: the dropped header names, the script location left out, form and multipart bodies parsed for any method, handler output ahead of the body, and long bodies split into 8 MiB writes.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…n Linux

On Linux, Rapira 0.9.0 corrupts the PHP VM stack around a dispatched exchange, and calling isCancelled() or isFinalized() on it in a method makes the worker segfault on every request (rapira-rs/rapira#201). The host respawns the worker silently and the client gets an empty reply, which is how the Dispatcher acceptance suite stopped coming up on the Linux CI jobs. The bridge no longer calls either. A cancelled exchange is now handled and fails its first write, which ends as a discarded response; the error answer relies on its own record of the head instead of isFinalized(). The test for skipping a cancelled exchange is skipped with its expectation kept, so reverting this commit once the host is fixed restores the check; #2 tracks that.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: boot the kernel before the first request in Worker and Dispatcher modes
feat: let an extending runtime answer such a failure through createFailureResponse()

A throwable that escapes kernel->handle() leaves the kernel in an unknown state and reached the client as the host's bare error; it now gets a plain 500, and the loop ends so Rapira runs the script afresh instead of serving on with that kernel. An error page is the bundle's call, so the runtime offers one method to override rather than rendering one itself.

Rapira counts a worker that ends before its first request as a failed start and one that ends after it as a planned recycle, so the kernel boots before the loop for a broken container to be counted, as roadrunner-bundle does.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: require Rapira 0.9.1

Rapira 0.9.1 fixes the worker crash after isCancelled() and isFinalized() (rapira-rs/rapira#201), so the workaround from c6c60c4 is reverted; 0.9.0 still crashes on x86_64 Linux, hence the new minimum.

Closes #2

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the kernel boots before the loop

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: leave the client's authority out of SERVER_NAME

Behind a proxy on a unix socket, REMOTE_ADDR held nothing or the socket path, so trusted_proxies matching on PRIVATE_SUBNETS failed in Dispatcher mode while Worker mode trusted the proxy.

The SAPI sets SERVER_NAME from the configured server name, which a dispatcher cannot see; filling it from the authority the client sent passed a client value off as a server one. Same change as rapira/http 0.1.6.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: keep a Dispatcher worker serving after a stream fails past its head
fix: keep application variables named like CGI ones in a request's $_SERVER
fix: stream a file response through PHP when Symfony's internal state is gone
fix: truncate input past PHP's limits instead of failing under the debug handler
fix: leave an empty upload input null in Dispatcher mode, as FileBag does

After handle_request() returns, the host has already removed the uploads and lifted max_execution_time, so terminate listeners lost the files and could hang the worker.

A body that fails after its head leaves the kernel intact, so only a damaged output buffer stack still ends the loop.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…cher mode

test: drop the nesting-level cookie test, which PHP never warns about
docs: say that a cut-off Dispatcher response is failed only after terminate

Classic and Worker modes log PHP's warning, so a silent truncation hid dropped input from operators.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
@Zylius
Zylius force-pushed the feat/symfony-runtime branch from 3419ee4 to afa1521 Compare October 12, 2026 19:12
@Zylius

Zylius commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@roxblnfk @Zylius I am still not happy for the many static calls, and some minor code related things, but there is nothing blocking the release itself AFAIK.

Has anyone tested this in their live project?

I can not test it in our production until I have a proper tag :/

@Zylius
Zylius merged commit 4376ba9 into 1.x Oct 12, 2026
11 checks passed
@Zylius
Zylius deleted the feat/symfony-runtime branch October 12, 2026 19:19
@github-actions github-actions Bot mentioned this pull request Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants