Skip to content

feat: roadrunner application server support - #153

Merged
markshust merged 4 commits into
developfrom
feature/roadrunner
Aug 29, 2026
Merged

feat: roadrunner application server support#153
markshust merged 4 commits into
developfrom
feature/roadrunner

Conversation

@markshust

Copy link
Copy Markdown
Collaborator

Adds marko/roadrunner, letting a Marko application be served by RoadRunner — booted once, serving many requests — instead of booting per request under PHP-FPM.

Stacked on #152. Base is feature/response-decoration, not develop. This PR needs #152's Response::headerLines(), Request::cookie(), ResettableInterface and Container::resolvedInstances(). Retarget to develop once #152 merges.

Purely additive

This PR touches no existing package. Everything it needed from existing code was fixed at the source in #152 — which is why the reset lifecycle here can stay generic instead of special-casing packages.

What it does

The request boundary was already pure: $router->handle(Request): Response. Application exposes a public virtual $router property, so the worker needed no core change at all.

  • worker.php — boots once, then serves. Kept to wiring; the loop lives in a testable WorkerRequestHandler.
  • PSR-7 bridges — request and response. PSR-7 stays confined to this package; a tests/Psr7ContainmentTest.php static scan fails the build if Psr\Http\Message, Nyholm\Psr7 or Spiral\RoadRunner appears under packages/*/src or packages/*/tests anywhere else.
  • Per-request reset — filters Container::resolvedInstances(ResettableInterface::class), sorts for deterministic ordering, resets before each request so a thrown or killed request cannot hand stale state forward. It never instantiates a service to reset it, so a service the request never used is correctly absent rather than needlessly constructed.
  • Guard rails — refuses to boot with marko/sse installed, naming the exact config key that downgrades it to a warning; warns on debugbar.
  • rr:serve and a working .rr.yaml.

The three hazards this had to solve

STDOUT is the goridge relay. RoadRunner's default pipes relay carries protocol frames on STDOUT. errors-simple's boot-registered handler checks isCli() — true in a worker — and echos its report straight into that stream. One uncaught throwable would corrupt the relay. The worker installs a safe exception handler and buffers every request, restoring ob_get_level() on all paths including exceptions.

The base path is not dirname(__DIR__, N). worker.php runs as vendor/marko/roadrunner/worker.php, which under Composer path repositories is a symlink back into the monorepo — exactly how this repo installs its own packages. Resolution order is MARKO_BASE_PATH, then the loaded Composer autoloader's own directory.

Presence of a package is a blunt signal. Hard-refusing marko/sse with no way out contradicts "every no comes with a yes, this way instead." The refusal names a config override, and states what an operator who takes it actually gets: a loud per-request 500 on the SSE route from the response bridge, not a silently truncated stream.

The spike is the interesting part

Rather than designing a reset interface up front, task 005 drove hundreds of requests through one booted application and audited every singleton declaration, boot-time instance() binding, mutable static, superglobal reader and process-global in the monorepo — recording an explicit verdict for each, including negatives so nobody re-investigates them. Findings: packages/docs-markdown/docs/packages/roadrunner-state-leaks.md.

It found two real cross-request leaks nobody had predicted:

  • Inertia::$shared — a singleton whose shared props merge into every subsequent render(). Middleware sharing the authenticated user once per request leaves that user visible to every following request. A cross-user data leak, not just memory growth.
  • ReadWriteConnection — a transaction left open by a request that threw, on a connection pooled across requests, silently absorbing the next request's writes.

Both are fixed in #152, at their source.

Verification

The end-to-end suite drives a real rr serve process pinned to one worker, so sequential requests provably hit the same process. Isolation cases run authenticated → anonymous → different user; the anonymous request in the middle is what catches a stale cached identity, which an A → B sequence would miss.

It is in the integration-destructive group, and nightly.yml now installs the RoadRunner binary — without that step the entire suite would have skipped in CI forever, so a CiWorkflowTest assertion guards it.

packages/roadrunner/src is added to phpstan.neon — the only non-core package under level-6 analysis, deliberately, because this is the one place a type error becomes a cross-user security bug.

7086 tests passing, PHPStan no errors, PHPCS clean.

Closes #151

🤖 Generated with Claude Code

@github-actions github-actions Bot added the enhancement New feature or request label Aug 28, 2026
Base automatically changed from feature/response-decoration to develop August 29, 2026 12:47
markshust and others added 4 commits August 29, 2026 08:47
…rd rails

Tasks 001-005, 007 and 008 of the roadrunner plan. Adds the marko/roadrunner
package with its monorepo wiring, the PSR-7 request and response bridges, the
worker accept loop, the in-process multi-request test harness, guard rails for
worker-unsafe packages, and the rr:serve command.

Includes the state-leak spike findings at
packages/docs-markdown/docs/packages/roadrunner-state-leaks.md, which record an
explicit verdict for every singleton, boot-time instance() binding, mutable
static, superglobal reader and process-global item in the monorepo. Two
confirmed leaks it found — Inertia::$shared and uncommitted transactions on a
pooled read/write connection — are fixed in #150.

The root .gitignore gains a negation for the harness fixture's vendor
directory. The fixture is a real Marko project tree, so module discovery
requires a directory literally named vendor/, but the unanchored vendor/ rule
matched it at depth — the fixture modules were never committed and the harness
would have found zero modules on a fresh clone or in CI.

Per-request reset wiring, the end-to-end test and the docs page follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
Completes the roadrunner plan. Adds the per-request reset lifecycle, the
end-to-end integration suite driven against a real RoadRunner process, and the
package documentation.

The reset is generic rather than a hardcoded list: it filters
Container::resolvedInstances(ResettableInterface::class), sorts for
deterministic ordering, and resets before each request so a thrown or killed
request cannot hand stale state to the next one. It never instantiates a
service in order to reset it, so a service the request never used is correctly
absent rather than needlessly constructed.

The end-to-end suite drives a real rr serve process pinned to a single worker,
so sequential requests provably hit the same process. Its isolation cases run
authenticated -> anonymous -> different user; the anonymous request in the
middle is what catches a stale cached identity, which an A -> B sequence would
miss. The suite is in the integration-destructive group, and nightly.yml now
installs the RoadRunner binary so it actually executes in CI rather than
skipping forever — a CiWorkflowTest assertion guards that step.

Documents the constraints a worker imposes: STDOUT is the goridge relay so
application output is buffered and discarded, request-scoped state in a
singleton is a cross-user leak, file uploads throw loudly, and marko/sse
refuses to boot with a named config override.

Closes #151

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
The root .gitignore excludes `vendor/` with an unanchored pattern, so it
matches at any depth — including inside test fixtures that model a real Marko
project tree, which must contain a directory literally named `vendor/` because
that is what module discovery looks for.

The failure mode is silent and local-only: the fixture's modules are never
committed, the tests pass off untracked files on the machine that wrote them,
and the fixture has nothing in it on a fresh clone or in CI.

This had already happened twice. It bit marko/roadrunner outright, and sat
latent in marko/codeindexer, whose fixture files were tracked from before the
rule existed and would have vanished had any been re-added.

Generalises the negation from one hardcoded path to every package's fixtures,
and adds tests/FixtureTrackingTest.php, which fails the build naming the
unreachable files and the fix. Verified to fail when the negation is removed.

Also aligns task 006's requirement text with the test name it actually ships
under, after the standards pass renamed it away from an internal artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
The package was scaffolded from queue-rabbitmq, which uses "library". But
marko/roadrunner is a module — it ships a module.php with bindings and sets
extra.marko.module — and the PR review checklist reserves "library" for
genuinely non-module libraries. 62 of the packages already use marko-module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 29, 2026
@markshust
markshust merged commit 95626f3 into develop Aug 29, 2026
4 checks passed
@markshust
markshust deleted the feature/roadrunner branch August 29, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add RoadRunner application server support (marko/roadrunner)

1 participant