feat: roadrunner application server support - #153
Merged
Conversation
markshust
force-pushed
the
feature/roadrunner
branch
from
August 29, 2026 12:40
1f98943 to
2af5b5d
Compare
…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
markshust
force-pushed
the
feature/roadrunner
branch
from
August 29, 2026 12:47
fa32aab to
dbe2f71
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
marko/roadrunner, letting a Marko application be served by RoadRunner — booted once, serving many requests — instead of booting per request under PHP-FPM.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.Applicationexposes a public virtual$routerproperty, so the worker needed no core change at all.worker.php— boots once, then serves. Kept to wiring; the loop lives in a testableWorkerRequestHandler.tests/Psr7ContainmentTest.phpstatic scan fails the build ifPsr\Http\Message,Nyholm\Psr7orSpiral\RoadRunnerappears underpackages/*/srcorpackages/*/testsanywhere else.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.marko/sseinstalled, naming the exact config key that downgrades it to a warning; warns on debugbar.rr:serveand a working.rr.yaml.The three hazards this had to solve
STDOUT is the goridge relay. RoadRunner's default
pipesrelay carries protocol frames on STDOUT.errors-simple's boot-registered handler checksisCli()— true in a worker — andechos its report straight into that stream. One uncaught throwable would corrupt the relay. The worker installs a safe exception handler and buffers every request, restoringob_get_level()on all paths including exceptions.The base path is not
dirname(__DIR__, N).worker.phpruns asvendor/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 isMARKO_BASE_PATH, then the loaded Composer autoloader's own directory.Presence of a package is a blunt signal. Hard-refusing
marko/ssewith 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 subsequentrender(). 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 serveprocess 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-destructivegroup, andnightly.ymlnow installs the RoadRunner binary — without that step the entire suite would have skipped in CI forever, so aCiWorkflowTestassertion guards it.packages/roadrunner/srcis added tophpstan.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