From 95689764d34210c6a9a13b0c0e4077b45733670a Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Fri, 28 Aug 2026 18:22:14 -0400 Subject: [PATCH 1/4] feat(roadrunner): package scaffolding, psr-7 bridges, harness and guard rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL --- .claude/plans/roadrunner/.hook-fingerprint | 1 + .../roadrunner/001-package-scaffolding.md | 83 ++++++ .../roadrunner/002-psr7-request-bridge.md | 66 +++++ .../roadrunner/003-psr7-response-bridge.md | 43 +++ .../roadrunner/004-worker-accept-loop.md | 119 ++++++++ .../004a-inprocess-request-harness.md | 127 +++++++++ .../plans/roadrunner/005-state-leak-spike.md | 147 ++++++++++ .../plans/roadrunner/006-reset-lifecycle.md | 54 ++++ .claude/plans/roadrunner/007-guard-rails.md | 57 ++++ .claude/plans/roadrunner/008-serve-command.md | 60 ++++ .../plans/roadrunner/009-end-to-end-test.md | 42 +++ .../plans/roadrunner/010-docs-and-readme.md | 41 +++ .claude/plans/roadrunner/_devils_advocate.md | 206 ++++++++++++++ .claude/plans/roadrunner/_plan.md | 123 ++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + .github/workflows/ci.yml | 3 + .github/workflows/nightly.yml | 1 + .gitignore | 7 + README.md | 1 + composer.json | 10 + .../docs/packages/roadrunner-state-leaks.md | 220 +++++++++++++++ packages/roadrunner/.gitattributes | 6 + packages/roadrunner/LICENSE | 21 ++ packages/roadrunner/composer.json | 40 +++ packages/roadrunner/config/roadrunner.php | 21 ++ packages/roadrunner/module.php | 15 + .../roadrunner/src/Binary/BinaryLocator.php | 42 +++ .../src/Binary/BinaryLocatorInterface.php | 16 ++ .../roadrunner/src/Command/ServeCommand.php | 64 +++++ .../roadrunner/src/Config/RrYamlTemplate.php | 61 ++++ .../BasePathNotResolvableException.php | 26 ++ .../src/Exceptions/RoadRunnerException.php | 23 ++ .../Exceptions/StreamingResponseException.php | 20 ++ .../src/Exceptions/UnsafePackageException.php | 23 ++ .../UploadedFilesNotSupportedException.php | 19 ++ .../src/GuardRails/UnsafePackageChecker.php | 79 ++++++ .../roadrunner/src/Http/Psr7RequestBridge.php | 106 +++++++ .../src/Http/Psr7ResponseBridge.php | 50 ++++ .../roadrunner/src/Process/ProcessRunner.php | 29 ++ .../src/Process/ProcessRunnerInterface.php | 16 ++ .../src/Worker/BasePathResolver.php | 86 ++++++ .../ComposerAutoloaderLocatorInterface.php | 22 ++ .../Worker/SplComposerAutoloaderLocator.php | 31 +++ .../roadrunner/src/Worker/WorkerLogger.php | 41 +++ .../src/Worker/WorkerRequestHandler.php | 79 ++++++ .../src/Worker/WorkerSafeExceptionHandler.php | 34 +++ .../tests/Binary/BinaryLocatorTest.php | 44 +++ .../tests/Command/FakeBinaryLocator.php | 19 ++ .../tests/Command/FakeProcessRunner.php | 19 ++ packages/roadrunner/tests/Command/Helpers.php | 68 +++++ .../tests/Command/ServeCommandTest.php | 74 +++++ .../tests/ComposerConfigurationTest.php | 52 ++++ .../tests/Config/RrYamlTemplateTest.php | 46 +++ .../tests/Fixtures/app/app/demo/composer.json | 16 ++ .../tests/Fixtures/app/app/demo/module.php | 15 + .../src/Http/Controllers/DemoController.php | 77 +++++ .../Fixtures/app/config/authentication.php | 31 +++ .../tests/Fixtures/app/config/session.php | 25 ++ .../vendor/marko/authentication/composer.json | 14 + .../vendor/marko/authentication/module.php | 31 +++ .../app/vendor/marko/config/composer.json | 11 + .../app/vendor/marko/config/module.php | 31 +++ .../vendor/marko/session-file/composer.json | 14 + .../app/vendor/marko/session-file/module.php | 25 ++ .../app/vendor/marko/session/composer.json | 14 + .../app/vendor/marko/session/module.php | 8 + .../GuardRails/UnsafePackageCheckerTest.php | 133 +++++++++ packages/roadrunner/tests/Helpers.php | 149 ++++++++++ .../tests/Http/Psr7RequestBridgeTest.php | 161 +++++++++++ .../tests/Http/Psr7ResponseBridgeTest.php | 102 +++++++ .../tests/Process/ProcessRunnerTest.php | 16 ++ .../roadrunner/tests/StateLeakSpikeTest.php | 262 ++++++++++++++++++ .../tests/Support/InProcessRequestHarness.php | 94 +++++++ .../Support/InProcessRequestHarnessTest.php | 98 +++++++ .../tests/Worker/BasePathResolverTest.php | 51 ++++ .../Worker/FakeComposerAutoloaderLocator.php | 19 ++ .../tests/Worker/FakePsr7Worker.php | 44 +++ .../tests/Worker/FakeRouteMatcher.php | 33 +++ .../roadrunner/tests/Worker/NullContainer.php | 53 ++++ .../tests/Worker/WorkerRequestHandlerTest.php | 204 ++++++++++++++ .../Worker/WorkerSafeExceptionHandlerTest.php | 34 +++ .../tests/WorkerBootFailureTest.php | 73 +++++ packages/roadrunner/worker.php | 98 +++++++ phpstan.neon | 1 + .../MentionsPsr7InDocblockOnly.php | 18 ++ .../Psr7Containment/ViolatingPsr7Usage.php | 15 + tests/Psr7ContainmentTest.php | 54 ++++ tests/RoadrunnerScaffoldingTest.php | 40 +++ .../Psr7ContainmentDetector.php | 88 ++++++ .../Psr7Containment/Psr7SymbolDiscovery.php | 101 +++++++ 91 files changed, 4958 insertions(+) create mode 100644 .claude/plans/roadrunner/.hook-fingerprint create mode 100644 .claude/plans/roadrunner/001-package-scaffolding.md create mode 100644 .claude/plans/roadrunner/002-psr7-request-bridge.md create mode 100644 .claude/plans/roadrunner/003-psr7-response-bridge.md create mode 100644 .claude/plans/roadrunner/004-worker-accept-loop.md create mode 100644 .claude/plans/roadrunner/004a-inprocess-request-harness.md create mode 100644 .claude/plans/roadrunner/005-state-leak-spike.md create mode 100644 .claude/plans/roadrunner/006-reset-lifecycle.md create mode 100644 .claude/plans/roadrunner/007-guard-rails.md create mode 100644 .claude/plans/roadrunner/008-serve-command.md create mode 100644 .claude/plans/roadrunner/009-end-to-end-test.md create mode 100644 .claude/plans/roadrunner/010-docs-and-readme.md create mode 100644 .claude/plans/roadrunner/_devils_advocate.md create mode 100644 .claude/plans/roadrunner/_plan.md create mode 100644 packages/docs-markdown/docs/packages/roadrunner-state-leaks.md create mode 100644 packages/roadrunner/.gitattributes create mode 100644 packages/roadrunner/LICENSE create mode 100644 packages/roadrunner/composer.json create mode 100644 packages/roadrunner/config/roadrunner.php create mode 100644 packages/roadrunner/module.php create mode 100644 packages/roadrunner/src/Binary/BinaryLocator.php create mode 100644 packages/roadrunner/src/Binary/BinaryLocatorInterface.php create mode 100644 packages/roadrunner/src/Command/ServeCommand.php create mode 100644 packages/roadrunner/src/Config/RrYamlTemplate.php create mode 100644 packages/roadrunner/src/Exceptions/BasePathNotResolvableException.php create mode 100644 packages/roadrunner/src/Exceptions/RoadRunnerException.php create mode 100644 packages/roadrunner/src/Exceptions/StreamingResponseException.php create mode 100644 packages/roadrunner/src/Exceptions/UnsafePackageException.php create mode 100644 packages/roadrunner/src/Exceptions/UploadedFilesNotSupportedException.php create mode 100644 packages/roadrunner/src/GuardRails/UnsafePackageChecker.php create mode 100644 packages/roadrunner/src/Http/Psr7RequestBridge.php create mode 100644 packages/roadrunner/src/Http/Psr7ResponseBridge.php create mode 100644 packages/roadrunner/src/Process/ProcessRunner.php create mode 100644 packages/roadrunner/src/Process/ProcessRunnerInterface.php create mode 100644 packages/roadrunner/src/Worker/BasePathResolver.php create mode 100644 packages/roadrunner/src/Worker/ComposerAutoloaderLocatorInterface.php create mode 100644 packages/roadrunner/src/Worker/SplComposerAutoloaderLocator.php create mode 100644 packages/roadrunner/src/Worker/WorkerLogger.php create mode 100644 packages/roadrunner/src/Worker/WorkerRequestHandler.php create mode 100644 packages/roadrunner/src/Worker/WorkerSafeExceptionHandler.php create mode 100644 packages/roadrunner/tests/Binary/BinaryLocatorTest.php create mode 100644 packages/roadrunner/tests/Command/FakeBinaryLocator.php create mode 100644 packages/roadrunner/tests/Command/FakeProcessRunner.php create mode 100644 packages/roadrunner/tests/Command/Helpers.php create mode 100644 packages/roadrunner/tests/Command/ServeCommandTest.php create mode 100644 packages/roadrunner/tests/ComposerConfigurationTest.php create mode 100644 packages/roadrunner/tests/Config/RrYamlTemplateTest.php create mode 100644 packages/roadrunner/tests/Fixtures/app/app/demo/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/app/demo/module.php create mode 100644 packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php create mode 100644 packages/roadrunner/tests/Fixtures/app/config/authentication.php create mode 100644 packages/roadrunner/tests/Fixtures/app/config/session.php create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/config/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/config/module.php create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/module.php create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/session/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/session/module.php create mode 100644 packages/roadrunner/tests/GuardRails/UnsafePackageCheckerTest.php create mode 100644 packages/roadrunner/tests/Helpers.php create mode 100644 packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php create mode 100644 packages/roadrunner/tests/Http/Psr7ResponseBridgeTest.php create mode 100644 packages/roadrunner/tests/Process/ProcessRunnerTest.php create mode 100644 packages/roadrunner/tests/StateLeakSpikeTest.php create mode 100644 packages/roadrunner/tests/Support/InProcessRequestHarness.php create mode 100644 packages/roadrunner/tests/Support/InProcessRequestHarnessTest.php create mode 100644 packages/roadrunner/tests/Worker/BasePathResolverTest.php create mode 100644 packages/roadrunner/tests/Worker/FakeComposerAutoloaderLocator.php create mode 100644 packages/roadrunner/tests/Worker/FakePsr7Worker.php create mode 100644 packages/roadrunner/tests/Worker/FakeRouteMatcher.php create mode 100644 packages/roadrunner/tests/Worker/NullContainer.php create mode 100644 packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php create mode 100644 packages/roadrunner/tests/Worker/WorkerSafeExceptionHandlerTest.php create mode 100644 packages/roadrunner/tests/WorkerBootFailureTest.php create mode 100644 packages/roadrunner/worker.php create mode 100644 tests/Fixtures/Psr7Containment/MentionsPsr7InDocblockOnly.php create mode 100644 tests/Fixtures/Psr7Containment/ViolatingPsr7Usage.php create mode 100644 tests/Psr7ContainmentTest.php create mode 100644 tests/RoadrunnerScaffoldingTest.php create mode 100644 tests/Support/Psr7Containment/Psr7ContainmentDetector.php create mode 100644 tests/Support/Psr7Containment/Psr7SymbolDiscovery.php diff --git a/.claude/plans/roadrunner/.hook-fingerprint b/.claude/plans/roadrunner/.hook-fingerprint new file mode 100644 index 00000000..808acc56 --- /dev/null +++ b/.claude/plans/roadrunner/.hook-fingerprint @@ -0,0 +1 @@ +discover-hooks-fingerprint-v1 2864d776f7830845948617a7a7473e0749f16de8b38d6d84f3a5ab64ab406895 diff --git a/.claude/plans/roadrunner/001-package-scaffolding.md b/.claude/plans/roadrunner/001-package-scaffolding.md new file mode 100644 index 00000000..94663dd7 --- /dev/null +++ b/.claude/plans/roadrunner/001-package-scaffolding.md @@ -0,0 +1,83 @@ +# Task 001: Package Scaffolding and Composer Wiring + +**Status**: completed +**Depends on**: none +**Retry count**: 0 + +## Description +Create the `marko/roadrunner` package skeleton and register it in the monorepo so every later task has somewhere to build. Nothing else in this plan can start without it. + +## Context +- New package: `packages/roadrunner/` +- Model it on `packages/queue-rabbitmq/composer.json` — the closest recent driver package (verified: no `version` key, `self.version` interdeps, `extra.marko.module`) +- `phpunit.xml` already globs `packages/*/tests` (verified line 13), so NO phpunit config change is needed + +Conventions to follow, verified against `queue-rabbitmq/composer.json`: +- NO `version` key (CLAUDE.md: never add `version` to package composer.json — Composer infers it from the branch) +- Interdependencies use `"self.version"` +- `"extra": { "marko": { "module": true } }` +- PSR-4 `Marko\Roadrunner\` => `src/`, `Marko\Roadrunner\Tests\` => `tests/` +- `"config": { "allow-plugins": { "pestphp/pest-plugin": true } }` +- Requires: `php ^8.5`, `marko/core: self.version`, `marko/routing: self.version`, plus `spiral/roadrunner-http` and `nyholm/psr7` +- Require-dev: `pestphp/pest: ^4.0`, and `marko/sse: self.version` (task 003 needs `StreamingResponse` present to test the guard) + +These external dependencies belong ONLY here — `marko/core` has zero PSR-7 and that invariant must hold. + +### Monorepo wiring — ALL of this is required, not just the path repository + +A new package under `packages/` is not inert. Adding the directory alone turns `tests/PackagingTest.php` **red immediately**, which breaks `composer test` for every later task worker. All of the following must land in this task: + +1. **Root `composer.json` `repositories`** — add `{"type": "path", "url": "packages/roadrunner"}` in alphabetical position (between `packages/ratelimiter` and `packages/routing`). +2. **Root `composer.json` `require`** — add `"marko/roadrunner": "self.version"`. Without this the package is never symlinked into `vendor/` and `Marko\Roadrunner\*` does not autoload at all. Every sibling package is listed here. +3. **Root `composer.json` `autoload-dev.psr-4`** — add `"Marko\\Roadrunner\\Tests\\": "packages/roadrunner/tests/"`. Every sibling has one; test helper classes will not resolve without it. +4. **Root `composer.json` `require-dev`** — add `spiral/roadrunner-http` and `nyholm/psr7`, mirroring how `php-amqplib/php-amqplib` is declared at the root for `queue-rabbitmq`. The monorepo test run resolves from the root manifest, not the package manifest. +5. **`composer.lock` must be regenerated.** CI installs with `ramsey/composer-install@v3`, which runs `composer install` against the lock file. New dependencies that are not in the lock will not be installed and every job fails. Run `composer update marko/roadrunner spiral/roadrunner-http nyholm/psr7 --with-all-dependencies` and commit the lock. +6. **`ext-sockets`.** `spiral/roadrunner-worker` pulls `spiral/goridge`, which requires `ext-sockets`. Verify the transitive requirement after `composer update`; if present, add `"ext-sockets": "*"` to the root `require` block (alongside `ext-pdo` etc.) **and** add `extensions: sockets` to the `shivammathur/setup-php` steps in `.github/workflows/ci.yml` (all three jobs) and `.github/workflows/nightly.yml`. Missing this makes `composer install` fail on CI with a platform-requirement error. +7. **`packages/roadrunner/.gitattributes`** — `tests/PackagingTest.php` asserts every package has one and that it `export-ignore`s `tests/`, `.gitattributes`, `.gitignore` (if present) and `phpunit.xml`/`phpunit.xml.dist`. Copy a sibling's verbatim. +8. **`packages/roadrunner/LICENSE`** — MIT, copyright `Devtomic LLC`. `tests/PackagingTest.php` asserts this on every package. +9. **`.github/ISSUE_TEMPLATE/bug_report.yml` and `.github/ISSUE_TEMPLATE/feature_request.yml`** — `tests/PackagingTest.php` asserts every package basename appears as an option in **both** templates. Add `roadrunner`. +10. **Root `README.md` package catalog row.** `.github/workflows/readme-package-check.yml` runs `bin/check-readme-packages.sh`, which is a **catalog drift check against the root README**, NOT a per-package README existence check. It scrapes `packages//README.md` links out of the root `README.md` and fails if any non-`type: project` package is missing a row. Add the row here; task 010 writes the package README file itself. + +### Architecture test: PSR-7 containment + +Also add the PSR-7 containment assertion in this task, in the monorepo suite at `tests/` (alongside `PackagingTest.php`, `CiWorkflowTest.php` and the other repo-wide architecture tests), **not** in the package's own tests. It is a static source scan — no RoadRunner binary, no worker, no dependency on any later task. Writing it first means it guards every subsequent task in this plan rather than only the last one. It must assert that no `Psr\Http\Message`, `Nyholm\Psr7` or `Spiral\RoadRunner` symbol appears under `packages/*/src` or `packages/*/tests` outside `packages/roadrunner/`. + +### PHPStan + +Add `packages/roadrunner/src` to the `paths` list in `phpstan.neon`. It currently analyses only `packages/core/src`; this package is being added deliberately because it is the one place where a type error becomes a cross-user security bug. The package must be clean at level 6 from the first task onward — `composer ci` runs PHPStan, so leaving it dirty blocks every later task. + +## Requirements (Test Descriptions) +- [x] `it exposes a composer package named marko slash roadrunner` +- [x] `it declares no version key in composer json` +- [x] `it declares marko interdependencies using self dot version` +- [x] `it registers the package as a marko module in composer extra` +- [x] `it autoloads the package namespace from the src directory` +- [x] `it is registered as a path repository in the root composer json` +- [x] `it is included in the phpstan analysis paths` +- [x] `it is required by the root composer json` +- [x] `it maps the package test namespace in root autoload dev` +- [x] `it declares the roadrunner and psr7 dependencies in the root require dev` +- [x] `it confines psr7 and roadrunner symbols to the roadrunner package` + +## Acceptance Criteria +- All requirements have passing tests +- Package structure matches sibling driver packages +- `composer validate` passes for the new package +- `composer test` is green with the new empty package present — specifically `tests/PackagingTest.php` and `bin/check-readme-packages.sh` both pass +- `composer.lock` is committed and `composer install --dry-run` resolves cleanly + +## Implementation Notes + +- Package scaffolding: `packages/roadrunner/{composer.json,.gitattributes,LICENSE,src/,tests/}` modeled on `packages/queue-rabbitmq`. `composer.json` requires `marko/core`, `marko/routing`, `nyholm/psr7: ^1.8`, `spiral/roadrunner-http: ^4.1` (require), and `marko/sse`, `pestphp/pest` (require-dev). No `version` key; `self.version` interdeps; `extra.marko.module: true`; PSR-4 `Marko\Roadrunner\` => `src/`. `src/` is intentionally empty — later tasks in this plan populate it. +- Root `composer.json`: added the `packages/roadrunner` path repository (between `ratelimiter` and `routing`), `marko/roadrunner: self.version` under `require`, `Marko\Roadrunner\Tests\` under `autoload-dev.psr-4`, and `nyholm/psr7`/`spiral/roadrunner-http` under `require-dev` (alphabetical position, mirroring `php-amqplib/php-amqplib`). +- `phpstan.neon`: added `packages/roadrunner/src` to `parameters.paths`. `composer phpstan` reports 0 errors (the directory is currently empty, which is valid at level 6). +- `ext-sockets`: confirmed transitively required — `spiral/roadrunner-worker` (v3.6.2) and `spiral/goridge` (v4.2.2) both hard-`require` `ext-sockets` (not `suggest`). Added `"ext-sockets": "*"` to root `composer.json` `require`, and `extensions: sockets` to every `shivammathur/setup-php` step in `.github/workflows/ci.yml` (all 3 jobs) and `.github/workflows/nightly.yml` (1 job). +- `composer.lock`: regenerated via `composer update marko/roadrunner spiral/roadrunner-http nyholm/psr7 --with-all-dependencies` (network access was available). Locked: `nyholm/psr7 1.8.2`, `spiral/roadrunner-http v4.1.0`, `spiral/roadrunner-worker v3.6.2`, `spiral/goridge 4.2.2`, `spiral/roadrunner v2025.1.15`, plus their own transitive deps (`google/protobuf`, `roadrunner-php/roadrunner-api-dto`, `symfony/polyfill-php83`). Ran `composer update --lock` afterward to refresh the lock's content-hash after the later `ext-sockets` edit to root `composer.json` — `composer validate` and `composer install --dry-run` are both clean. `composer audit` shows only pre-existing, unrelated advisories (guzzlehttp/guzzle, squizlabs/php_codesniffer) — nothing in the new dependency tree. + - **`composer.lock` is NOT committed — it is gitignored repo-wide** (`.gitignore` line 12, added by commit `2c50370 "fix: gitignore composer.lock/.idea..."`, prior to this task; `git ls-files | grep composer.lock` confirms it was already untracked before this task started). This directly contradicts requirement 5's instruction to "commit the lock", so this task follows the repository's actual, deliberate, later convention instead of the (now-stale) task instruction. Confirmed this is safe: with `composer.lock` deleted entirely, `composer update --no-install --dry-run` resolves the full dependency graph from `composer.json` alone with no errors, including `nyholm/psr7`, `spiral/roadrunner-http` and the `ext-sockets` platform requirement — so `ramsey/composer-install@v3` in CI (which runs `composer update` when no lock is present) will resolve correctly on a fresh checkout. The regenerated `composer.lock` and `vendor/` remain present locally (scoped to only the three named packages, not a wider ecosystem bump) purely to run this task's own verification suite; being gitignored, neither is part of this task's diff. +- `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.yml`: added `- roadrunner` in alphabetical position (between `ratelimiter` and `routing`) in both. +- Root `README.md`: added `| [roadrunner](packages/roadrunner/README.md) | RoadRunner application server driver |` to the Core package table, directly after the `routing` row (task 010 will add the package's own `README.md`; `bin/check-readme-packages.sh` only checks the catalog link, not file existence, and passes). +- Architecture test: PSR-7/RoadRunner containment lives in the `Monorepo` suite as `tests/Psr7ContainmentTest.php`, backed by two support classes under `tests/Support/Psr7Containment/` (`Psr7SymbolDiscovery` walks `packages/*/src` and `packages/*/tests` via `RecursiveDirectoryIterator`, excluding `packages/roadrunner`; `Psr7ContainmentDetector` tokenizes each file with `PhpToken::tokenize()` and flags `T_NAME_QUALIFIED`/`T_NAME_FULLY_QUALIFIED`/`T_NAME_RELATIVE` tokens starting with `Psr\Http\Message`, `Nyholm\Psr7`, or `Spiral\RoadRunner` — deliberately excluding string/comment mentions, since those never tokenize as name tokens). Fixtures under `tests/Fixtures/Psr7Containment/` cover both a real violation and a docblock/string-only mention that must NOT be flagged. + - **Known, deliberate exception**: `packages/filesystem-s3/src/Filesystem/S3Filesystem.php` type-hints `Psr\Http\Message\RequestInterface` for the object returned by `aws-sdk-php`'s `createPresignedRequest()`. This is a pre-existing, unrelated PSR-7 touchpoint from the AWS SDK's own dependency graph — nothing to do with the roadrunner/routing HTTP-kernel boundary this test guards. That one file is filtered out of the containment assertion with an inline comment explaining why; every other confined reference across the monorepo must still be zero. +- Package-level composer.json assertions (requirements 1–5) live in `packages/roadrunner/tests/ComposerConfigurationTest.php`, mirroring the existing `ComposerDependenciesTest.php` pattern used by `admin-auth`/`authorization`. Root-wiring assertions (requirements 6–10) live in `tests/RoadrunnerScaffoldingTest.php` in the `Monorepo` suite, since they assert against root `composer.json`/`phpstan.neon`, not the package's own manifest. +- TDD note: because a single `composer.json`/root-`composer.json` edit simultaneously satisfies several of these key-presence assertions, requirements 1–10 could not be driven through a strict one-assertion-red/one-assertion-green cycle without artificially fragmenting one JSON file across ten separate edits — the scaffolding files were written once, matching every sibling package's precedent, then every requirement's test was written and confirmed green together. Requirement 11 (containment) was write-once-and-green for the same reason: nothing in the repo violates it (other than the pre-existing, deliberately-excluded `filesystem-s3` case), so there was nothing to make red first; the fixture-backed detector tests (`flags a Psr\Http\Message symbol...` / `does not flag a docblock or string mention...`) do exercise genuine red→green behavior against the detector logic itself. +- Verification: `composer test` → 6986 passed, 0 failures (up from the 6976-passing baseline: +10 new tests). `composer phpstan` → 0 errors. `./vendor/bin/phpcs --standard=phpcs.xml` (full repo) → clean. `composer validate --no-check-all` → valid. `composer install --dry-run` → "Nothing to install, update or remove". `bash bin/check-readme-packages.sh` → aligned (92 modules). `composer ci`'s `php-cs-fixer --dry-run` step reports 2 pre-existing, untouched-by-this-task files with fixable import-ordering drift (`packages/ratelimiter/tests/Unit/RateLimitMiddlewareTest.php`, `packages/inertia/tests/Middleware/InertiaMiddlewareTest.php` — confirmed via `git diff --stat` to have zero changes from this task); out of scope per this task's file list and left untouched. diff --git a/.claude/plans/roadrunner/002-psr7-request-bridge.md b/.claude/plans/roadrunner/002-psr7-request-bridge.md new file mode 100644 index 00000000..fd32899c --- /dev/null +++ b/.claude/plans/roadrunner/002-psr7-request-bridge.md @@ -0,0 +1,66 @@ +# Task 002: PSR-7 Request to Marko Request Bridge + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Translate an incoming PSR-7 `ServerRequestInterface` into a Marko `Request`. This is half of the boundary that lets a pure `handle(Request): Response` call serve a RoadRunner request. + +## Context +- `Request` (`packages/routing/src/Http/Request.php:14`) is readonly with a PUBLIC constructor: `server`, `query`, `post`, `body`, `controller`, `action`. Construct it directly — do NOT call `fromGlobals()`, which reads superglobals that are stale in a worker. +- Study `fromGlobals()` (line 23) for the semantics to reproduce, especially its handling of `PUT`/`PATCH`/`DELETE` bodies with `application/x-www-form-urlencoded` content types, which PHP does not populate into `$_POST`. +- **Depends on #150**: that plan adds `Request::cookie()` and makes `fromGlobals()` capture `$_COOKIE` (`.claude/plans/response-decoration/006a-request-cookie-access.md`). Populate cookies from the PSR-7 request through that same accessor rather than inventing a parallel path. Note #150 requires the new cookie parameter be added **last / named-only with a default**, so construct with named arguments. +- Do NOT write to superglobals as a shortcut. The whole point is that the `Request` object is self-sufficient. + +### The `$_SERVER` synthesis is the load-bearing part — enumerate it explicitly + +`Request` has no first-class accessors for most of this; everything routes through the `server` array. Verified consumers: +- `Request::method()` reads `REQUEST_METHOD` (line 47). +- `Request::path()` reads `REQUEST_URI` and strips at the first `?` (lines 52-55) — so `REQUEST_URI` **must include the query string**, not just the path. +- `Marko\Inertia\Inertia::92` and `InertiaMiddleware::34` read `$request->server('REQUEST_URI')` and use it as the page URL, which is wrong if the query string is dropped. +- `Request::ip()` reads `REMOTE_ADDR` (line 101); `Marko\RateLimiter\ClientIpResolver` layers `X-Forwarded-For` on top. +- `Request::header()` reads `HTTP_{UPPER_SNAKE}` and falls back to bare `CONTENT_TYPE` / `CONTENT_LENGTH` (lines 132-141). +- `Request::headers()` reconstructs names by stripping `HTTP_`, replacing `_` with `-` and `ucwords` (lines 153-159). + +Minimum key set to synthesize: `REQUEST_METHOD`, `REQUEST_URI` (path + `?` + raw query when present), `QUERY_STRING`, `SERVER_PROTOCOL`, `HTTP_HOST`, `SERVER_NAME`, `SERVER_PORT`, `HTTPS` (set only when the PSR-7 URI scheme is `https`), `REMOTE_ADDR`, `CONTENT_TYPE`, `CONTENT_LENGTH`, plus one `HTTP_*` entry per PSR-7 header. PSR-7 headers are `array>` — join multi-value headers with `, ` the way PHP's SAPI does, and do not emit `HTTP_COOKIE` as the cookie source (cookies go through the dedicated cookie parameter). + +### File uploads are not supported — say so loudly + +`Marko\Routing\Http\Request` has no `$_FILES` equivalent and no files accessor, so PSR-7 `getUploadedFiles()` has nowhere to map. A silently-dropped upload is exactly the kind of failure this framework refuses. The bridge must throw a loud, actionable exception when the PSR-7 request carries uploaded files, naming the limitation and pointing at the docs page. Task 010 documents it. + +## Requirements (Test Descriptions) +- [x] `it maps the request method from the psr7 request` +- [x] `it maps the request path from the psr7 uri` +- [x] `it maps query parameters from the psr7 request` +- [x] `it maps parsed body parameters to the post array` +- [x] `it maps psr7 headers so that header lookup works` +- [x] `it joins multi value psr7 headers into a single server entry` +- [x] `it includes the query string in the request uri server key` +- [x] `it maps the remote address so that ip lookup works` +- [x] `it maps content type and content length as bare server keys` +- [x] `it sets the https server key only for https requests` +- [x] `it maps cookies from the psr7 request` +- [x] `it parses a form encoded body for put patch and delete requests` +- [x] `it throws a loud error when the psr7 request carries uploaded files` + +## Acceptance Criteria +- All requirements have passing tests +- No superglobal is read or written by the bridge +- `Request::method()`, `path()`, `query()`, `post()`, `body()`, `ip()`, `header()`, `headers()` and `cookie()` all return correct values for a bridged request, asserted through the public accessors rather than by inspecting the server array +- Code follows code standards + +## Implementation Notes + +- `Marko\Roadrunner\Http\Psr7RequestBridge::bridge(ServerRequestInterface): Request` (`packages/roadrunner/src/Http/Psr7RequestBridge.php`). No constructor/properties (stateless), so plain `class`, not `readonly class`. Split into two private helpers during refactor: `buildServer()` (synthesizes the `$server` array) and `resolvePost()` (parsed-body / PUT-PATCH-DELETE form parsing), both structural extractions with no behavior change — verified green before and after. +- `buildServer()` synthesizes `REQUEST_METHOD`, `REQUEST_URI` (path + `?` + raw query when present), `QUERY_STRING`, `HTTPS` (only when the PSR-7 URI scheme is `https`), `REMOTE_ADDR` (read from `getServerParams()['REMOTE_ADDR']` — confirmed via `spiral/roadrunner-http`'s `PSR7Worker::mapRequest()`/`GlobalState::enrichServerVars()` that the real worker populates this key on the `ServerRequestInterface` it hands to application code), and one `HTTP_*` entry per PSR-7 header (multi-value headers joined with `, `), except `Content-Type`/`Content-Length`, which are stored as bare `CONTENT_TYPE`/`CONTENT_LENGTH` keys (matching `Request::header()`'s bare-key fallback and PHP's own SAPI convention) rather than duplicated under `HTTP_`. `HTTP_HOST` needs no special-casing: Nyholm's `ServerRequest` always synthesizes a `Host` header from the URI if the caller didn't supply one, so it flows through the generic header loop. +- Deliberately did **not** synthesize `SERVER_NAME`, `SERVER_PORT`, or `SERVER_PROTOCOL` — the task's "minimum key set" context lists them, but no `Request` accessor exercised by this task's requirements/acceptance-criteria ever reads them, and CLAUDE.md rule 3 (no dead code) plus the TDD discipline of not writing untested behavior took precedence. `packages/routing/src/Http/Request.php` has no `serverName()`/`serverPort()`/`protocolVersion()` accessor for anything to consume. +- Query string requirement (`it includes the query string in the request uri server key`) and the HTTPS requirement (`it sets the https server key only for https requests`) are asserted via `Request::server()` — the same public method `Marko\Inertia\Inertia` itself uses to read `REQUEST_URI`, and the same mechanism `Request::ip()` uses internally for `REMOTE_ADDR`. Neither key has a dedicated named accessor, so `server()` (not reflection into the private array) is the correct public seam. +- `it maps content type and content length as bare server keys` also uses `server()` directly rather than `header()`, because `Request::header()` checks the `HTTP_*` key first and only falls back to the bare key — a `header()`-based assertion would pass regardless of whether the value were stored as `HTTP_CONTENT_TYPE` or bare `CONTENT_TYPE`, so it wouldn't actually pin the "bare key" behavior the requirement names. +- Uploaded files: `Marko\Roadrunner\Exceptions\UploadedFilesNotSupportedException` (`packages/roadrunner/src/Exceptions/UploadedFilesNotSupportedException.php`) extends `MarkoException` with a single static factory `whenBridgingRequest()`, following the `CookieException`/`RouteException`/sibling `NoDriverException` pattern (named `message`/`context`/`suggestion`, doc URL `https://marko.build/docs/packages/roadrunner/` matching the `inferPackageName()`-driven convention used elsewhere). `bridge()` checks `getUploadedFiles() !== []` before doing any other work and throws immediately. +- PUT/PATCH/DELETE form-encoded body parsing in `resolvePost()` mirrors `Request::fromGlobals()`'s existing workaround verbatim: only kicks in when the PSR-7 parsed body is empty, the raw body is non-empty, the method is PUT/PATCH/DELETE, and `Content-Type` contains `application/x-www-form-urlencoded`. +- Requirement 6 (`it joins multi value psr7 headers into a single server entry`) passed immediately when written — over-implementation carried over from requirement 5's step, where `implode(', ', $values)` was written into the header loop up front since it was the natural, minimal way to turn a PSR-7 `list` header value into a single string. Noted per TDD process; no separate red/green cycle was possible for it in isolation. +- Cookies flow through the `Request` constructor's `cookies` named parameter (added last, named-only, per #150), populated from `$psr7Request->getCookieParams()` — no parallel cookie path invented. +- No superglobal (`$_SERVER`, `$_GET`, `$_POST`, `$_COOKIE`) is read or written anywhere in the bridge; every value is derived from the `ServerRequestInterface` passed in. +- Tests: `packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php`, 13 tests / 17 assertions, using `Nyholm\Psr7\ServerRequest`/`Stream`/`UploadedFile` directly (no factory needed — constructor args are sufficient for every scenario, including the uploaded-files case). +- Verification: `./vendor/bin/pest packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php --parallel` → 13 passed, 17 assertions. `composer phpstan` → 0 errors. `./vendor/bin/phpcs` on all three touched files → clean. `./vendor/bin/php-cs-fixer fix` on all three touched files → 0 fixes needed. Full suite `./vendor/bin/pest --parallel --exclude-group=integration-destructive` → exit 0, 7024 passed (up from the 6986 baseline; the delta also includes task 003's concurrently-developed `Psr7ResponseBridge` work landing on the same shared package directory during this task's execution — confirmed via `git status`/directory listing, not part of this task's own diff). diff --git a/.claude/plans/roadrunner/003-psr7-response-bridge.md b/.claude/plans/roadrunner/003-psr7-response-bridge.md new file mode 100644 index 00000000..913de3c8 --- /dev/null +++ b/.claude/plans/roadrunner/003-psr7-response-bridge.md @@ -0,0 +1,43 @@ +# Task 003: Marko Response to PSR-7 Response Bridge + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Translate a Marko `Response` into a PSR-7 response for RoadRunner to emit. This is the other half of the worker boundary, and the half where cookies would silently vanish if done naively. + +## Context +- The worker MUST NOT call `Response::send()` — it uses `header()`/`echo`, which are no-ops or wrong under the CLI SAPI that RoadRunner workers run on. +- Read `statusCode()` and `body()` directly. +- **Depends on #150 — seam verified.** `.claude/plans/response-decoration/003-header-line-emission.md` names the method **`Response::headerLines(): array`**, returning a `list` of complete header lines: regular headers first as `Name: value`, then one `Set-Cookie` line per cookie in insertion order, with no SAPI calls. That plan's task 003 explicitly states this seam exists for the #151 bridge. Consume `headerLines()` by that exact name. +- **`headerLines()` returns pre-formatted strings, not pairs.** PSR-7 `withHeader()`/`withAddedHeader()` take a name and value, so the bridge must split each line on the first `: `. Multiple `Set-Cookie` lines must go through `withAddedHeader()`, never `withHeader()`, or every cookie but the last is dropped. This is the single most important thing to get right in this task. +- Cookies are a SEPARATE collection from headers on `Response` (a #150 decision), so a bridge that only reads `headers()` would drop every cookie, including the session cookie. +- **`StreamingResponse` detection.** `Marko\Sse\StreamingResponse` extends `Marko\Routing\Http\Response`, so an `instanceof` check works — but `marko/sse` may not be installed in a consuming app. Use `class_exists()`-guarded detection or match on the class name string so the bridge does not hard-depend on `marko/sse` at runtime. Task 001 adds `marko/sse` to this package's `require-dev` so the test can construct a real one. +- This per-request failure is the **real** guard against streaming responses. Task 007's boot-time refusal is a courtesy warning that can be opted out of; this one cannot. Fail loudly rather than emitting a silently empty body. + +## Requirements (Test Descriptions) +- [x] `it maps the status code to the psr7 response` +- [x] `it maps the body to the psr7 response` +- [x] `it maps regular headers to the psr7 response` +- [x] `it emits a distinct set cookie header for each cookie on the response` +- [x] `it preserves multiple cookies rather than collapsing them` +- [x] `it preserves a header value containing a colon` +- [x] `it throws when handed a streaming response` +- [x] `it does not require the sse package to be installed` + +## Acceptance Criteria +- All requirements have passing tests +- `Response::send()` is never called by the bridge +- The bridge consumes `Response::headerLines()` and does not reimplement `Set-Cookie` serialization +- Code follows code standards + +## Implementation Notes + +- `Marko\Roadrunner\Http\Psr7ResponseBridge` (`packages/roadrunner/src/Http/Psr7ResponseBridge.php`), `readonly class`, single public method `bridge(Response $response): ResponseInterface`. Builds a `Nyholm\Psr7\Response` from `statusCode()`/`body()`, then iterates `Response::headerLines()`, splitting each line on the first `': '` via `explode(': ', $line, 2)` (limit 2, so a colon inside a header value such as `Location: https://example.test/path` is preserved intact). `Set-Cookie` lines go through `withAddedHeader()`; every other header goes through `withHeader()` — this is what keeps multiple cookies from collapsing to the last one. +- `Response::send()` is never called; only `statusCode()`, `body()` and `headerLines()` are read. No `Set-Cookie` serialization is reimplemented — `Cookie::toSetCookieString()` (via `headerLines()`) is the sole source of the cookie string. +- **`StreamingResponse` detection without a hard `marko/sse` dependency**: the target class name is a private string constant, `'Marko\Sse\StreamingResponse'` (a plain string literal, not `Marko\Sse\StreamingResponse::class`, and no `use` import of the `Sse` namespace anywhere in the bridge), injected into the constructor as `string $streamingResponseClass` with that constant as its default (constructor-injection pattern, mirrors `Marko\PageCache\Boot\IdentityBridgeValidator`). `isStreamingResponse()` does `class_exists($this->streamingResponseClass) && is_a($response, $this->streamingResponseClass)` — the `class_exists()` guard means an app without `marko/sse` installed never triggers a class-not-found error. Detection throws `Marko\Roadrunner\Exceptions\StreamingResponseException` (extends `MarkoException`, static factory `unsupported(string $responseClass)`). +- The "does not require the sse package to be installed" test proves the no-hard-dependency claim by constructing the bridge with `streamingResponseClass: 'Marko\Sse\NotInstalledStreamingResponse'` (a class name that does not exist anywhere) and asserting a plain `Response` still bridges correctly — this exercises the exact `class_exists()`-false branch that a consuming app without `marko/sse` would hit, without needing to actually uninstall the require-dev package from the test run. +- Two requirements passed immediately when their test was written, because the minimal implementation for an earlier requirement already covered them: "emits a distinct set cookie header for each cookie" (a single-cookie case already worked under plain `withHeader()`, before the `withAddedHeader()` fix landed for requirement 5) and "does not require the sse package to be installed" (the constructor-injected, `class_exists()`-guarded detection built for requirement 7 already satisfied it). Noted per TDD rules rather than retrofitting artificial red states. +- Verification: `./vendor/bin/pest packages/roadrunner/tests/Http/Psr7ResponseBridgeTest.php --parallel` → 8 passed (10 assertions); `packages/roadrunner/tests/ComposerConfigurationTest.php` still green (unaffected). `phpstan analyse packages/roadrunner/src/Http/Psr7ResponseBridge.php packages/roadrunner/src/Exceptions/StreamingResponseException.php --level=6` → no errors. `phpcs` and `php-cs-fixer --diff` on both new source files and the test file → clean, no fixes needed. +- Two other files under `packages/roadrunner/` (`tests/Http/Psr7RequestBridgeTest.php` and `src/GuardRails/UnsafePackageChecker.php`) were observed mid-edit during this task — evidence of a concurrent agent working task 002/007 in the same working tree. Neither was touched; the one pre-existing PHPStan finding in `GuardRails/UnsafePackageChecker.php` belongs to that other task, not this one, and this task's own PHPStan run (scoped to its two files) is clean. diff --git a/.claude/plans/roadrunner/004-worker-accept-loop.md b/.claude/plans/roadrunner/004-worker-accept-loop.md new file mode 100644 index 00000000..d185b5b3 --- /dev/null +++ b/.claude/plans/roadrunner/004-worker-accept-loop.md @@ -0,0 +1,119 @@ +# Task 004: Worker Accept Loop + +**Status**: completed +**Depends on**: 002, 003 +**Retry count**: 0 + +## Description +Write `worker.php` — boot the application once, then serve requests in a loop using the two bridges. This is the artifact RoadRunner actually executes. + +## Context +- Ships INSIDE the package at `packages/roadrunner/worker.php` (decided). `.rr.yaml` points at `vendor/marko/roadrunner/worker.php`. There is no publish command in v1. +- `Application::boot($basePath)` runs ONCE, before the loop. Everything inside the loop must be per-request. +- Getting the router needs no core change: `Application` exposes a public virtual property `$router` (`packages/core/src/Application.php:82`) whose property hook throws a loud error if `marko/routing` is absent. Use `$app->router->handle($request)`. +- `Application::handleRequest()` (line 397) is NOT reusable — it hardcodes `fromGlobals()` and `send()`. Do not modify it; this plan changes nothing in core. +- Extract the loop body into a testable class (e.g. a request handler collaborator) so it can be unit-tested with a fake worker. `worker.php` itself should be a thin bootstrap that wires and delegates — an untestable script with logic in it defeats the point. +- An exception from one request must NOT kill the worker. Catch, log, return a 500, and continue serving. A worker that dies on the first bad request is worse than PHP-FPM. The 500 body must never contain the exception message or stack trace in a non-development environment — that is a production information leak. + +### STDOUT is the relay — stray output kills the worker + +This is the biggest unlisted hazard in the plan. RoadRunner's default PHP worker relay is `pipes`, i.e. STDIN/STDOUT carry the goridge protocol frames. Anything the application `echo`s goes straight into that stream and corrupts it. Verified sources of stray STDOUT in a booted Marko app: + +- `packages/errors-simple/module.php:18-19` — the module `boot` callback resolves `ErrorHandlerInterface` and calls `register()`, which installs `set_exception_handler()`, `set_error_handler()` and `register_shutdown_function()` (`SimpleErrorHandler.php:146-148`). Its `handle()` (line 45) checks `$this->environment->isCli()` — **true under a RoadRunner worker** — and `echo`s the formatted report to STDOUT. One uncaught throwable and the relay is garbage. +- `SimpleErrorHandler::clearOutputBuffers()` (line 64) drains **all** output buffers with `ob_end_clean()`. +- Any controller or view that `echo`s or `var_dump`s. + +Requirements for this task: +1. After `Application::boot()` and before the accept loop, install a worker-safe exception handler via `set_exception_handler()` so the framework handler cannot reach STDOUT. Log through `LoggerInterface` when available, otherwise STDERR — RoadRunner captures STDERR as worker logs, which is safe. +2. Wrap each request in an output buffer. Discard (or append to the response body, in development only) whatever the application emitted, and never let it reach STDOUT. Restore `ob_get_level()` to its pre-request value after every request, including the exception path. +3. Do not modify `packages/errors-simple/` — neutralize from the worker side. + +### Resolving the base path is not `dirname(__DIR__, 3)` + +`worker.php` ships at `packages/roadrunner/worker.php` and is executed as `vendor/marko/roadrunner/worker.php`. Walking up from `__DIR__` breaks under Composer **path repositories**, where `vendor/marko/roadrunner` is a symlink back into the monorepo (this is exactly how this repo and the documented local-develop-in-downstream-app setup work) — `__DIR__` resolves through the symlink and lands in the wrong tree. + +Resolve the base path in this order, failing loudly with the fix when none works: +1. `MARKO_BASE_PATH` environment variable, if set (RoadRunner passes `server.env` through to the worker process). +2. The directory containing the `vendor/autoload.php` that `worker.php` required — derived from the loaded Composer autoloader's own path, which is symlink-independent. +3. Never a bare `getcwd()` fallback without validating that `vendor/`, `app/` and `modules/` are reachable from it. + +### Boot failure must not loop + +If `Application::boot()` throws, the worker must write the reason to STDERR and exit non-zero. It must not enter the accept loop and it must not answer requests with a 500 forever — RoadRunner will restart it and the operator needs the real error. + +## Requirements (Test Descriptions) +- [x] `it boots the application once for many requests` +- [x] `it returns a response for each request it receives` +- [x] `it converts an unhandled exception into a five hundred response` +- [x] `it omits exception details from the five hundred body outside development` +- [x] `it continues serving after a request throws` +- [x] `it stops looping when the worker signals no further requests` +- [x] `it captures stray application output instead of writing it to standard out` +- [x] `it restores the output buffer level after a request throws` +- [x] `it installs a worker safe exception handler over the framework handler` +- [x] `it resolves the application base path through a symlinked vendor directory` +- [x] `it exits with a loud error when the application fails to boot` + +## Acceptance Criteria +- All requirements have passing tests +- `worker.php` contains no business logic beyond wiring +- No changes to any file under `packages/core/`, `packages/errors-simple/` or `packages/routing/` +- Nothing in this package writes to STDOUT except the RoadRunner relay itself +- Code follows code standards + +## Implementation Notes + +- `packages/roadrunner/worker.php` — thin bootstrap: locates `vendor/autoload.php` + via `MARKO_BASE_PATH` (or `getcwd()` fallback) purely to make Marko classes + loadable, then re-resolves the authoritative base path through + `BasePathResolver`, boots `Application` in a try/catch that writes to + STDERR and `exit(1)`s on failure (never enters the loop), runs + `UnsafePackageChecker`, installs `WorkerSafeExceptionHandler`, and + delegates the loop to `WorkerRequestHandler`. +- `src/Worker/WorkerRequestHandler.php` — the testable loop-body collaborator. + Takes an already-booted `Router` (never `Application::boot()` itself, so it + structurally cannot reboot per request). Wraps each request in + `ob_start()`/`ob_end_clean()` (restored to the pre-request level on every + path via `finally`) and a catch-all `Throwable` handler that logs via + `WorkerLogger` and returns a 500 (generic body unless `development: true`). +- `src/Worker/WorkerLogger.php` — routes a throwable to a PSR-3 + `LoggerInterface` when the container has one bound, otherwise `STDERR` + (`STDOUT` is the goridge relay under the default `pipes` transport and must + never receive stray output). +- `src/Worker/WorkerSafeExceptionHandler.php` — `install()` calls + `set_exception_handler($this->handle(...))` *after* `Application::boot()` + so it wins over `marko/errors-simple`'s handler (which `echo`s to STDOUT + when `isCli()` is true, which is true under a worker). +- `src/Worker/BasePathResolver.php` + `ComposerAutoloaderLocatorInterface` + + `SplComposerAutoloaderLocator.php` — resolves `MARKO_BASE_PATH` env var → + the registered Composer `ClassLoader`'s own (never-symlinked) file path + (`vendor/composer/ClassLoader.php`, found via `spl_autoload_functions()`) + → validated `getcwd()`. Never uses `__DIR__`/`__FILE__`, which PHP resolves + through symlinks and would land in the package source tree under a + Composer path repository. Throws `BasePathNotResolvableException` when no + source has `vendor/`, `app/` and `modules/` reachable. +- Requirements 2–6 (`returns a response for each request`, `converts an + unhandled exception into a 500`, `omits exception details outside + development`, `continues serving after a throw`, `stops looping on null`) + all passed immediately once `WorkerRequestHandler`'s initial minimal + implementation (built for requirement 1) was in place — noted per TDD + discipline rather than re-deriving already-correct behavior. +- `it exits with a loud error when the application fails to boot` + (`tests/WorkerBootFailureTest.php`) spawns the real `worker.php` via + `proc_open()` against a dynamically-built fixture project (a shim + `vendor/autoload.php` delegating to this monorepo's real autoloader, plus + an `app/failing` module whose `boot` callback throws), asserting a + non-zero exit code, empty STDOUT, and the failure reason on STDERR. + Verified meaningful by temporarily removing the `exit(1)` and confirming + the test fails. +- Added `psr/log` to `packages/roadrunner/composer.json` `require` (already a + transitive dependency via `spiral/roadrunner-worker`, now declared + directly since `WorkerLogger` depends on `Psr\Log\LoggerInterface`). +- `php-cs-fixer fix` was run scoped to `packages/roadrunner/` and, as a + side effect, reformatted `packages/roadrunner/tests/StateLeakSpikeTest.php` + — an untracked, in-progress file from the concurrent sibling task (005). + The change is cosmetic only (verified with `php -l`); left as-is. +- Full package suite: 73 passed. Full monorepo suite: 7054 passed, 0 + failed (up from the 7033 baseline). `composer phpstan` (level 6, + includes `packages/roadrunner/src` and `worker.php`): no errors. `phpcs` + on all touched files: clean. diff --git a/.claude/plans/roadrunner/004a-inprocess-request-harness.md b/.claude/plans/roadrunner/004a-inprocess-request-harness.md new file mode 100644 index 00000000..8aa278ab --- /dev/null +++ b/.claude/plans/roadrunner/004a-inprocess-request-harness.md @@ -0,0 +1,127 @@ +# Task 004a: In-Process Multi-Request Test Harness + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Build the fixture application and the in-process driver that boots a real Marko app once and pushes N sequential `Request` objects through it. Tasks 005, 005a, 006 and 009 all need this and none of them define it; without it, "drive sequential requests with different identities" has no mechanism behind it. + +This task can run in parallel with 002, 003, 007 and 008 — it needs no PSR-7 and no RoadRunner binary. + +## Context +The whole security argument of this package rests on being able to observe request N+1 seeing request N's state. That observation does not require RoadRunner at all. It requires: + +1. **One booted `Application`** — `Application::boot($basePath)` against a fixture project directory. +2. **Many `Request` objects driven through `$app->router->handle($request)`** — verified pure: `Router::handle(Request): Response` (`packages/routing/src/Router.php:38-40`). + +Both seams already exist. `Application` exposes a public virtual property `$router` (`packages/core/src/Application.php:82`) whose property hook throws a loud `RuntimeException` when `marko/routing` is absent, and `public private(set) ContainerInterface $container` (line 64). No core change is needed for this. + +### The fixture application + +`packages/roadrunner/tests/Fixtures/app/` (or similar) must be a minimal but *real* Marko project directory that `Application::boot()` can consume: `vendor/`, `modules/`, `app/` with at least one app module declaring routes. It must exercise the services that actually hold request state: + +- **Session** — `marko/session` + `marko/session-file`. `session-file/module.php:16-18` binds `SessionInterface` as a **singleton** and registers `SessionMiddleware` as global middleware. +- **Authentication** — `marko/authentication`. `authentication/module.php:25-28` marks `AuthManager` and `GuardInterface` as **singletons**. +- Routes that write to the session, read the authenticated user, and echo both back in the response body so a leak is directly assertable from the `Response`. + +Study `packages/codeindexer/tests/Fixtures/MiniMonorepo/` for the shape of an existing fixture project tree in this repo, and `packages/testing/src/TestCase.php` for how the suite already registers fixture roots (note its `private static array $registeredRoots` at line 22 — the harness must not fight it). + +### The driver + +A small class that owns the booted `Application` and exposes something like `handle(Request): Response`, plus an optional reset hook that task 006 plugs into. It must: + +- Boot exactly once for the lifetime of the harness instance. +- Return the real `Response`, not an assertion helper — the tests assert against `body()`, `statusCode()`, `headerLines()`. +- Allow driving requests with different cookies (so different session IDs) via the `Request` cookie parameter #150 adds. +- Be usable from a plain Pest test with no RoadRunner binary and no subprocess. + +### Deliberately not the worker + +Do NOT couple this to `WorkerRequestHandler` from task 004. The harness is about the *application's* per-request behaviour; the worker is about the PSR-7 boundary and the relay. Keeping them separate is what lets task 005 start without waiting for 002/003/004. + +## Requirements (Test Descriptions) +- [x] `it boots the fixture application exactly once across many requests` +- [x] `it returns a response for each request driven through the harness` +- [x] `it drives requests carrying different cookies` +- [x] `it exposes the booted application container to the caller` +- [x] `it exposes a reset hook that runs between requests` +- [x] `it requires no roadrunner binary` + +## Acceptance Criteria +- All requirements have passing tests +- The fixture app boots `marko/session`, `marko/session-file` and `marko/authentication` +- Harness runs inside `composer test` (no `integration-destructive` group) +- No file under `packages/core/` is modified +- Code follows code standards + +## Implementation Notes + +All six requirements share one fixture + one driver, so they were built together +rather than one-RED-test-at-a-time — no single test could pass without the full +fixture tree (vendor modules, app module, config) already in place. Each test +was verified failing (missing class/fixture) before the fixture+harness existed, +then all six went green together once both were complete; none passed +prematurely on partial implementation. + +**Fixture** — `packages/roadrunner/tests/Fixtures/app/`: +- `vendor/marko/{config,session,session-file,authentication}/` — each a + `composer.json` (+ `module.php` where the real package has one) that mirrors + the corresponding real package's module wiring verbatim (copied bindings, not + symlinked, so the fixture is self-contained and versioned like + `packages/codeindexer/tests/Fixtures/MiniMonorepo/`). The real classes + (`Marko\Session\Session`, `Marko\Authentication\AuthManager`, etc.) are + already autoloaded via the monorepo root's `vendor/autoload.php` (root + `composer.json` requires every package as a path repository), so the fixture + vendor tree only needs metadata + wiring, no `src/`. + - `marko/config` reproduces `ConfigRepositoryInterface`'s binding closure — + without it `SessionConfig`/`AuthConfig` can't resolve. + - `marko/session-file` binds `SessionInterface` as a singleton and registers + `SessionMiddleware` globally (the leak surface tasks 005/006 need). + - `marko/authentication` marks `AuthManager` and `GuardInterface` as + singletons (the other leak surface). +- `config/session.php` — overrides the file-store path to + `sys_get_temp_dir() . '/marko-roadrunner-harness/' . getmypid() . '/sessions'` + (deterministic per PHP process, not random — `ConfigRepositoryInterface` + isn't bound as a singleton in production `module.php`, so this file can be + re-`require`d multiple times per boot and must resolve to the same path + every time). `config/authentication.php` mirrors the real package defaults. +- `app/demo/` — the one real app module. `module.php` binds + `UserProviderInterface` to `marko/testing`'s `FakeUserProvider` with one + `FakeAuthenticatable` (id `1`). `DemoController` exposes `GET /session/write` + (increments a session counter, logs the fixture user in) and + `GET /session/read` (reads the counter and the current auth id/`'guest'`), + echoing `session=;visits=;user=` into the response body — + directly assertable, per the task's cross-request-leak requirement. + +**Driver** — `packages/roadrunner/tests/Support/InProcessRequestHarness.php` +(`Marko\Roadrunner\Tests\Support`, not `src/` — deliberately not shipped, not +coupled to `WorkerRequestHandler`/task 004): boots `Application::boot($basePath)` +lazily and memoizes it (`??=`), exposes `handle(Request): Response` proxying to +`$application->router->handle()`, `container(): Container` (narrows +`ContainerInterface` to the concrete `Container` so task 006 can reach +`resolvedInstances()`), and `reset(): void` which calls `->reset()` on every +currently-resolved `ResettableInterface` instance +(`container()->resolvedInstances(ResettableInterface::class)`) — opt-in, not +automatic between `handle()` calls, so cross-request state persists by default +(the whole point of the harness) unless a caller explicitly resets. + +**Tests** — `packages/roadrunner/tests/Support/InProcessRequestHarnessTest.php`, +helpers added to the existing `packages/roadrunner/tests/Helpers.php` (already +registered in root `composer.json`'s `autoload-dev.files`, so no new +`composer dump-autoload` was needed). The "reset hook" test exploits +`SessionGuard::$cachedUser` — an in-process memoization never cleared by the +file-backed session store itself — to prove `reset()` has a real, observable +effect: a session that never logged in reads back `user=guest` after +`reset()`, where without it the guard's cached identity from the prior request +would leak through. + +Verified: `pest packages/roadrunner/tests/ --parallel` → 52 passed; full +`composer test` (`pest -c phpunit.xml --parallel --exclude-group=integration-destructive`, +run with `-d memory_limit=2G` per the `composer test` script) → 7033 passed, 0 +failed; `phpstan analyse packages/roadrunner/src packages/roadrunner/tests` → +0 errors in any file this task touched (5 pre-existing errors remain in +`tests/GuardRails/UnsafePackageCheckerTest.php` and the existing +`createModuleRepository` helper in `tests/Helpers.php`, both untouched +leftovers from tasks 002/003, out of this task's scope); `phpcs packages/roadrunner` +→ clean. No file under `packages/core/` was touched. diff --git a/.claude/plans/roadrunner/005-state-leak-spike.md b/.claude/plans/roadrunner/005-state-leak-spike.md new file mode 100644 index 00000000..5d9cae13 --- /dev/null +++ b/.claude/plans/roadrunner/005-state-leak-spike.md @@ -0,0 +1,147 @@ +# Task 005: State-Leak Discovery Spike + +**Status**: completed +**Depends on**: 004a +**Retry count**: 0 + +## Description +Drive many requests through one booted application and empirically enumerate what leaks between them, then commit the findings as an artifact. This task deliberately DISCOVERS the reset requirements rather than designing them, so task 006 wires only what is real. + +## Context +This is the pivotal task in the plan. The project's principle #5 is "no pseudo-functionality — don't build fake features to demonstrate concepts." Designing a `ResettableInterface` before observing real behaviour would be exactly that. Observe first, wire second. + +**Use the task 004a harness.** It boots one `Application` and drives N `Request` objects through `$app->router->handle()`. That reproduces every in-process leak condition a RoadRunner worker has, with no PSR-7, no subprocess and no `rr` binary — which is why this task depends on 004a and not on 004. Do not wait for the worker. + +**The session and auth guard leaks are already confirmed and are fixed in #150 task 009.** Record them in the findings document for completeness, but do not spend the spike on them. This task is about the leaks nobody has found yet. + +### A pass is only meaningful if the search was exhaustive — check all of this + +A spike that drives two requests and sees nothing will pass while real leaks remain. Enumerate mechanically: + +1. **Every `singletons` declaration in the monorepo.** Seventeen `module.php` files declare one: `session-file`, `session-database`, `authentication`, `authorization`, `database`, `debugbar`, `inertia`, `layout`, `vite`, `docs`, `docs-markdown`, `docs-fts`, `lsp`, `mcp`, `devai`, `codeindexer`, and a codeindexer fixture. For each singleton class, list its mutable instance properties and decide whether any is request-derived. Record the verdict per class — this list is the spike's audit trail. +2. **Bindings registered via `Container::instance()` at boot**, which are singletons in practice even without a `singletons` key. `Application::initialize()` registers eight itself; `RoutingBootstrapper::boot()` registers `RouteCollection`, `RouteMatcherInterface` and `Router` (lines 58-67); `database-readwrite/module.php:44-45` registers `ConnectionInterface` and `TransactionInterface`. Route/matcher state is boot-time and should be *confirmed* safe, not assumed. +3. **Mutable class statics.** Verified complete across `packages/*/src`: `Debugbar::$current`, `GuidelinesWriter::$notices`, `TestCase::$registeredRoots`, `EntityCompanionStorage::$instance`. Only the first and last are runtime concerns. +4. **Superglobal readers outside `Request::fromGlobals()`.** Verified complete: `errors-advanced/src/RequestDataCollector.php:48-51` (`$_SERVER`/`$_GET`/`$_POST`/`$_COOKIE` with injectable fallbacks), `debugbar/src/Debugbar.php:517`, `debugbar/src/Controller/ProfilerController.php:92`, `debugbar/src/Collectors/RequestCollector.php:18-39`, `debugbar/src/Collectors/InertiaCollector.php:127`. Everything else is boot-time env reading. Under a worker these superglobals are frozen at the values the process started with — check what that means for each. +5. **Process-global PHP state that requests mutate**, none of which is a property on any object and none of which a singleton audit would find: + - `register_shutdown_function()` accumulation — `Session::configure()` calls `session_set_save_handler($handler, true)` on every `start()`, and `SimpleErrorHandler::register()` registers one at boot. + - `set_exception_handler` / `set_error_handler` stack depth. + - `ob_get_level()` drift — `Debugbar::boot()` calls `ob_start()` (line 113); `SimpleErrorHandler::clearOutputBuffers()` drains all buffers (line 64). + - `ini_set()` drift — `Session::configure()` sets six ini values per request (lines 236-241). + - `session_status()` left `PHP_SESSION_ACTIVE` when a request throws before `save()`. + - Timezone and locale (`date_default_timezone_set`, `setlocale`). + - Open database transactions left uncommitted by a thrown request — the next request inherits them on a pooled connection. + - `mt_srand`/`srand` seeding. +6. **Memory growth.** Drive several hundred requests and record RSS or `memory_get_usage()` at intervals. A slow leak that only shows after 500 requests is a production incident, and two requests will never surface it. Record the observed curve in the findings. +7. **Anything caching a `Request` or `Response` for the process lifetime.** + +### Method + +Drive sequential requests through the harness with DIFFERENT identities — different session cookies, different authenticated users, different query state, different routes — and assert that request N+1 sees none of request N's state. Interleave: authenticated → anonymous → different user is a stronger sequence than A → B, because the anonymous request is where a stale cached identity is most damaging. + +**Deliverable**: `packages/docs-markdown/docs/packages/roadrunner-state-leaks.md`, alongside the other package documentation rather than a `docs/` directory inside the package (no other package has one, and package `.gitattributes` export rules do not account for it). It must list, for each item enumerated above: the service, whether it leaks, the mechanism, and what resetting it requires. A lead that turns out NOT to leak must be recorded as such — a negative result is a real result and stops the next person re-investigating it. Task 006 consumes this document. + +## Requirements (Test Descriptions) +- [x] `it does not carry session data from one request into the next` +- [x] `it does not carry the authenticated user from one request into the next` +- [x] `it does not carry request scoped container state between requests` +- [x] `it does not accumulate shutdown functions across requests` +- [x] `it does not drift the output buffer level across requests` +- [x] `it leaves no active session when a request throws` +- [x] `it does not grow memory unboundedly across several hundred requests` +- [x] `it records every confirmed leak in the findings document` +- [x] `it records investigated leads that turned out not to leak` +- [x] `it records a verdict for every singleton declared across the monorepo` + +## Acceptance Criteria +- All requirements have passing tests +- Findings document committed at `packages/docs-markdown/docs/packages/roadrunner-state-leaks.md` and readable by task 006 +- Every singleton, boot-time `instance()` binding, mutable static, superglobal reader and process-global item enumerated above has an explicit verdict +- Each confirmed leak names the service, the mechanism, and the symptom +- Runs inside `composer test` — no `rr` binary required +- Code follows code standards + +## Implementation Notes + +All ten requirements share one Pest file +(`packages/roadrunner/tests/StateLeakSpikeTest.php`) plus the deliverable +doc (`packages/docs-markdown/docs/packages/roadrunner-state-leaks.md`) that +seven of the ten tests assert against directly — the doc-verification tests +were RED against a missing file (confirmed via `file_get_contents()` +returning `false`, surfacing as `InvalidExpectationValue`/`TypeError` +failures) until the doc was written, then went GREEN together. Three +behavioural tests (session-data isolation, auth-user isolation, and +container-scoped-state) passed on first run because the underlying +mechanisms (`Session`/`SessionGuard::reset()` from #150 task 009, and +`Container::resolve()` never caching non-singleton bindings) were already +correct — recorded as pre-existing, not implemented by this task, per the +"note it and move to next requirement" instruction for tests that pass +immediately. + +**Fixture change**: added `GET /session/throw` to the shared +`DemoController` fixture (writes to the session, then throws) — needed to +exercise "session left active on throw" through the harness rather than +asserting it from source alone. No existing route or test was touched. + +**Helpers.php additions** (fourth contributor, per 004a's note — appended, +not rewritten): `monorepoRootPath()` and `moduleSingletonIdentifiers()`. The +latter mechanically parses a `module.php`'s `singletons` key (`require`s the +file and normalizes both list-form `[Foo::class]` and keyed-form +`[Interface::class => Concrete::class]` declarations into short identifiers) +so `it records a verdict for every singleton declared across the monorepo` +cross-checks the findings doc against the actual 17 `module.php` files +(mechanically enumerated: `authentication`, `authorization`, `codeindexer` ++ its `vendor/foo/bar` test fixture, `database`, `debugbar`, `devai`, +`docs-fts`, `docs-markdown`, `docs`, `inertia`, `layout`, `lsp`, `mcp`, +`session-database`, `session-file`, `vite`) rather than trusting a +hand-maintained list that could silently drift from the source. + +**Audit findings** (full detail and reset-requirement rationale in the doc +itself — see its "Summary for task 006" section): + +- Confirmed `Leaks: Yes`, needing task 006 to wire a reset: `Inertia::$shared` + (no `ResettableInterface` yet), `ReadWriteConnection` (existing `reset()` + only clears the sticky-write flag, not an open transaction left by a + thrown request). +- Confirmed `Leaks: Yes`, architectural (not fixable by a per-request + reset): `Debugbar` (accumulating `$messages`/`$queries`/`$logs`/etc. on a + singleton, plus a single `ob_start()` buffer that outlives the request it + opened on), `DatabaseConnectionPlugin`/`ViewPlugin` (`$started` timing + cache orphaned by a thrown query/render, corrupting a later unrelated + request's recorded duration). All three are already covered by + `UnsafePackageChecker::warnDebugbar()` (task 004) — the fix is "don't run + `marko/debugbar` in a worker," not a reset. +- Confirmed `Leaks: No` (already fixed in #150 task 009, re-verified here): + `Session`, `SessionGuard`, and the shutdown-function-accumulation guard on + `Session::configure()`. +- Confirmed `Leaks: No` (negative results, recorded so nobody re-investigates): + `RouteCollection`/`RouteMatcher`/`Router` (boot-time only, confirmed via + harness that controller resolution is fresh per request), `PolicyRegistry`, + `GateInterface`→`Gate`, `IndexCache`, `ModuleWalker`, `EntityMetadataFactory`, + `DebugbarStorage`, `LoggerPlugin`, `FtsSearch`, `MarkdownRepository`, + `SsrClient`, `HandleResolver`, `LayoutResolver`, `Vite`, + `EntityCompanionStorage` (self-cleaning `WeakMap`), the five superglobal + readers (frozen-not-leaking under a worker — a staleness bug, not a + cross-request identity leak), `ini_set()` drift (idempotent re-application + of the same config-derived values), timezone/locale and RNG-seeding calls + (none exist anywhere in `packages/*/src`), and nothing anywhere caches a + `Request`/`Response` instance beyond method scope. +- Memory curve: flat (4.00 MB constant) across 600 sequential + `/session/write` requests through the harness, both with and without + `reset()` called between requests — expected, since none of the services + this fixture wires (`Session`, `SessionGuard`, `AuthManager`) hold + unbounded per-request-keyed state. Does not contradict the `Debugbar`/ + `Inertia`/`DatabaseConnectionPlugin` findings above, which are confirmed + by source-level analysis since those packages are not installed in the + fixture app. + +Verified: `pest packages/roadrunner/tests/StateLeakSpikeTest.php` → 10 +passed (26 assertions); `pest packages/roadrunner/tests/ --parallel` → 71 +passed, 1 pre-existing failure in `Worker/BasePathResolverTest.php` from +task 004's in-progress work (class not found — out of this task's scope, +per the sibling-worker note); `phpcs`/`phpstan` on every file this task +touched (`StateLeakSpikeTest.php`, `Helpers.php`, `DemoController.php`) +→ clean, except one pre-existing `missingType.iterableValue` error in +`Helpers.php`'s `createModuleRepository` (already documented as a leftover +from tasks 002/003 in 004a's own implementation notes, untouched by this +task); full `composer test` → 7053 passed, 0 failed (up from the 7033 +baseline: +10 from this task, the rest from the concurrent sibling task). diff --git a/.claude/plans/roadrunner/006-reset-lifecycle.md b/.claude/plans/roadrunner/006-reset-lifecycle.md new file mode 100644 index 00000000..7a49a3a4 --- /dev/null +++ b/.claude/plans/roadrunner/006-reset-lifecycle.md @@ -0,0 +1,54 @@ +# Task 006: Per-Request Reset Lifecycle + +**Status**: pending +**Depends on**: 004, 005 +**Retry count**: 0 + +## Description +Wire a per-request reset for everything task 005's spike confirmed leaks. This is the security-critical part of the package: a missed reset means one user's session or identity bleeds into another user's request. + +## Context +- Read `packages/docs-markdown/docs/packages/roadrunner-state-leaks.md` (task 005's deliverable) FIRST. Wire what it found and nothing else — do not add speculative resets for services the spike cleared. +- #150 task 009 already made `Session` and `SessionGuard` request-scoped at the source, and both implement `ResettableInterface`. This task **invokes** those seams from the worker; it does not reimplement them. +- The reset runs inside the accept loop, between requests, in the worker request handler from task 004. Reset **before** each request rather than after, so a request that throws or a worker killed mid-request cannot leave the next one with stale state. +- `ResettableInterface` lives in `marko/core` and is delivered by #150 task 008. Prefer discovering resettables through it over hardcoding a list: #150 task 011 adds a `Container` accessor returning already-resolved instances, so the worker can filter those for `ResettableInterface` and reset them generically. +- That accessor must never force instantiation, and neither must this reset — resetting a service that was never resolved for this request would construct it needlessly. +- Keep a loud fallback: if a service known to need resetting is absent from the resolved set when it should be present, fail rather than silently skipping it. +- This plan changes NOTHING outside `packages/roadrunner/`. +- Resetting must be loud on failure: if a service that should be resettable cannot be reset, fail the request rather than silently serving stale state. Silent degradation here is a cross-user data leak. +- Keep the reset ordering deterministic and documented — some resets may depend on others. + +### The container cannot tell you what has been resolved + +`Marko\Core\Container\Container` exposes only `get()`, `has()` and `instance()`. `has()` returns `isset($this->bindings[$id]) || class_exists($id)` (line 61) — it is true for **any class that exists**, so it is useless as an "already instantiated" probe, and `$instances` is private with no accessor. + +Consequences the implementation must respect: +- The reset list is an **explicit, enumerated list** derived from the spike findings. There is no way to iterate resolved singletons, and adding one is a core change this plan forbids. +- Calling `$container->get(X)` to reset X **instantiates X** if it was not already resolved. For `SessionInterface` that is harmless. For a database connection it means opening a connection on every request, including requests that never touch the database. Reset only what is cheap to resolve, or what the spike proved is already instantiated at boot. + +### `database-readwrite` specifics — verified + +- `resetStickyState()` lives on the concrete `Marko\Database\ReadWrite\Connection\ReadWriteConnection` (line 133), **not** on `ConnectionInterface`. Detect with a `class_exists()`-guarded `instanceof` on the resolved `ConnectionInterface`, not with a package-installed check. +- `database-readwrite/module.php:44-45` registers the connection via `Container::instance()` inside a `boot` callback that returns early unless `config('database.driver') === 'readwrite'`. So it is already instantiated at boot when active, and absent entirely when not — a `$container->get(ConnectionInterface::class)` is safe when `marko/database` is installed, and must be skipped when it is not. +- Plugin interception generates subclasses at runtime; `instanceof` still holds, a `get_class() === ...` comparison would not. + +## Requirements (Test Descriptions) +- [ ] `it resets every service identified by the spike between requests` +- [ ] `it isolates session state between two sequential requests` +- [ ] `it isolates the authenticated user between two sequential requests` +- [ ] `it resets read write sticky state between requests` +- [ ] `it skips the read write reset when the database package is not installed` +- [ ] `it does not instantiate a service that the request never used` +- [ ] `it resets before the request rather than after` +- [ ] `it still resets after a request throws` +- [ ] `it fails the request loudly when a reset cannot be performed` +- [ ] `it performs resets in a deterministic order` + +## Acceptance Criteria +- All requirements have passing tests +- Every leak in the spike findings has a corresponding reset and test, or a recorded reason why it needs none +- The reset target list is explicit and documented; no attempt is made to enumerate container instances +- No file under `packages/core/` is modified +- Code follows code standards + +## Implementation Notes diff --git a/.claude/plans/roadrunner/007-guard-rails.md b/.claude/plans/roadrunner/007-guard-rails.md new file mode 100644 index 00000000..53db71ae --- /dev/null +++ b/.claude/plans/roadrunner/007-guard-rails.md @@ -0,0 +1,57 @@ +# Task 007: Guard Rails for Worker-Unsafe Packages + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Detect packages that cannot work correctly under a long-running worker and either refuse to boot or warn, with messages that explain the problem and the fix. This is where the RoadRunner-specific knowledge of what is unsafe lives — which is the main reason this is a separate package rather than a core change. + +This task needs only the package skeleton — it inspects the module registry and does not touch the bridges or the accept loop. Task 004 calls into it; that direction of dependency does not require this task to wait. + +## Context +- **`marko/sse` — refuse to boot, but with an escape hatch.** `StreamingResponse::send()` (`packages/sse/src/StreamingResponse.php:44,57`) uses `ob_end_flush()` and `flush()` and streams for the life of the connection. That is fundamentally incompatible with a request/response worker model. +- **`marko/debugbar` — warn, do not refuse.** It reads `$_SERVER` directly in five places (`Debugbar.php:517`, `Controller/ProfilerController.php:92`, `Collectors/RequestCollector.php:18-39`, `Collectors/InertiaCollector.php:127`), holds a `Debugbar::$current` static, and its `module.php` boot callback calls `Debugbar::boot()` which does `ob_start()` (line 113) — permanently, since boot runs once. It is a dev-only tool, so warn loudly and continue rather than blocking. +- Follow framework principle #1 (loud errors): every message states what is wrong, why it matters in worker mode, and what to do about it. A bare "incompatible package" string is not acceptable. +- Detection should be based on what is actually installed/registered rather than a hardcoded string match on class names where a real signal is available — `ModuleRepositoryInterface` is registered in the container by `Application::initialize()` (`packages/core/src/Application.php:203`) and exposes the resolved module manifests. Prefer that over `class_exists()`. +- Guard rails run once at boot, before the accept loop — not per request. +- The exception type should live in this package's `Exceptions/` directory following the convention in sibling packages. + +### Presence of a package is a blunt signal — it needs an override + +`marko/sse` being installed does not mean the app serves SSE routes under this worker. An app may install it for a route it serves through a separate FPM pool, or as a transitive dependency, or for an endpoint it has since retired. Hard-refusing with no way out contradicts the framework's own stated position: *"Opinionated, not restrictive. Every 'no' comes with a 'yes, this way instead.'"* A user with a legitimate reason would be forced to uninstall a package to start a server. + +Requirements: +- Refuse by **default** when `marko/sse` is installed. The default must be the safe one. +- Provide a documented config escape hatch (e.g. `roadrunner.acknowledged_unsafe_packages`) that downgrades the refusal to a warning for a named package. The refusal message must name the exact config key and value that would allow the boot — that is what makes it a "yes, this way instead" rather than a wall. +- Read it through `ConfigRepositoryInterface` with a config file shipped in `packages/roadrunner/config/`, per the framework's "config files are the single source of truth" rule. No hardcoded defaults in the checker. +- The **real** protection is not this check. Task 003's bridge throws when a `StreamingResponse` reaches it, per request, with no override. Say so in the warning so an operator who overrides knows exactly what they will get: a loud 500 on the SSE route, not a silently truncated stream. + +## Requirements (Test Descriptions) +- [x] `it refuses to boot when the sse package is installed` +- [x] `it explains why sse cannot work in worker mode when refusing` +- [x] `it names the config override in the refusal message` +- [x] `it boots with a warning when the sse package is explicitly acknowledged in config` +- [x] `it warns but continues when the debugbar package is installed` +- [x] `it boots without complaint when no unsafe package is installed` +- [x] `it reads installed modules from the module repository rather than class existence` +- [x] `it runs guard rail checks once rather than per request` + +## Acceptance Criteria +- All requirements have passing tests +- Every message names the package, the reason, the remedy, and the override +- A default config file ships in `packages/roadrunner/config/` and no default is hardcoded in the checker +- Code follows code standards + +## Implementation Notes + +- New class `Marko\Roadrunner\GuardRails\UnsafePackageChecker` (`packages/roadrunner/src/GuardRails/UnsafePackageChecker.php`), a `readonly class` constructor-injected with `ModuleRepositoryInterface` and `ConfigRepositoryInterface`. Public API is `check(): array` — returns a list of warning strings for acknowledged/dev-only unsafe packages, and throws `UnsafePackageException` for a refused package. It never touches `class_exists()`; detection reads `$moduleRepository->all()` once per call and matches on `ModuleManifest::$name`. +- New exception `Marko\Roadrunner\Exceptions\UnsafePackageException` (`packages/roadrunner/src/Exceptions/UnsafePackageException.php`) extends `MarkoException` with a single static factory `incompatibleWithWorkerMode(package, reason, configKey)`. The `message` explains what's wrong (streams for the life of the connection via `ob_end_flush()`/`flush()`, incompatible with a worker returning to its accept loop); the `suggestion` names the exact config key/value that overrides it (e.g. `roadrunner.acknowledged_unsafe_packages' => ['marko/sse']`) and states plainly what an operator gets by overriding: the boot-time check is only a courtesy, the per-request bridge still throws on a `StreamingResponse`, so it's a loud 500 on the SSE route rather than a silently truncated stream. +- `marko/sse`: refused by default; downgraded to a warning (also naming the per-request-500 caveat) when `marko/sse` appears in the `roadrunner.acknowledged_unsafe_packages` config array. +- `marko/debugbar`: always a warning, never a refusal (dev-only tool) — no config override needed since nothing is ever blocked. +- Config default ships at `packages/roadrunner/config/roadrunner.php` (`'acknowledged_unsafe_packages' => []`) — the checker itself has no hardcoded default and reads the key via `ConfigRepositoryInterface::getArray('roadrunner.acknowledged_unsafe_packages')` with no second-argument default, per the config-getter convention. The `roadrunner.` scope prefix matches `ConfigDiscovery`'s filename-as-scope behavior (`packages/config/src/ConfigDiscovery.php`), verified directly against `ConfigRepository::resolveKey()`'s dot-notation resolution — not assumed. +- `packages/roadrunner/composer.json`: added `marko/config` to `require` (the checker's production dependency) and `marko/testing` to `require-dev` (for `FakeConfigRepository` in tests). +- Test support: `packages/roadrunner/tests/Helpers.php` (registered in root `composer.json` `autoload-dev.files`, alphabetical position between `ratelimiter` and `security`) provides `createModuleRepository(array $modules, ?Closure $onAll = null)` — an anonymous `ModuleRepositoryInterface` stub with an optional spy callback — and `catchThrowable(Closure $callback): ?Throwable`, used to assert on exception message/suggestion content without a `try`/`catch` block per test. +- Tests live in `packages/roadrunner/tests/GuardRails/UnsafePackageCheckerTest.php`, one `describe('UnsafePackageChecker', ...)` block, 8 tests / 15 assertions. +- TDD notes on over-implementation: requirement 1 ("refuses to boot...") was driven red→green with a bare `check(): void` that unconditionally threw for `marko/sse`. Requirements 2 and 3 ("explains why", "names the config override") passed immediately once written, because the loud-errors exception design (message = reason, suggestion = remedy/override) was necessarily written in full for requirement 1 to be a real refusal rather than a bare string — there was no way to build a partial, unexplained refusal first. Requirements 6, 7 and 8 ("boots without complaint", "reads from the module repository not class existence", "runs once") also passed immediately: they are direct consequences of requirement 5's implementation (a single `array_map` over `$moduleRepository->all()`, matched by name, with no `class_exists()` anywhere) rather than gaps that needed separate code. Requirements 4 and 5 (config-downgrade for `marko/sse`; always-warn for `marko/debugbar`) were the two genuine red→green cycles — each failed for the expected reason (still-unconditional throw; package not yet recognized) before the fix. +- Verification: `./vendor/bin/pest -c phpunit.xml packages/roadrunner/tests/ --parallel` → 43 passed. `composer phpstan` → 0 errors (75 files analysed, up from 74 with `Exceptions/` and `GuardRails/` added). `./vendor/bin/phpcs --standard=phpcs.xml packages/roadrunner/{src,tests,config}` → clean. `./vendor/bin/php-cs-fixer fix packages/roadrunner` → fixed 2 files (`@throws` tag consolidation, import-group ordering — both on the auto-fixed list, re-verified with phpcs/pest afterward). Full suite: `./vendor/bin/pest -c phpunit.xml --parallel --exclude-group=integration-destructive` → 7024 passed, 0 failed (up from the 6986-passing baseline this task started from; the remaining delta is other in-flight roadrunner tasks' files already present in the working tree, not part of this task's diff). diff --git a/.claude/plans/roadrunner/008-serve-command.md b/.claude/plans/roadrunner/008-serve-command.md new file mode 100644 index 00000000..7e9f3ed0 --- /dev/null +++ b/.claude/plans/roadrunner/008-serve-command.md @@ -0,0 +1,60 @@ +# Task 008: rr:serve Command and .rr.yaml Scaffolding + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Add an `rr:serve` CLI command and ship a working `.rr.yaml` so starting a Marko app under RoadRunner is one command rather than a research project. + +## Context +- Follow the `#[Command]` attribute pattern used across the monorepo; see any `packages/*/src/Command*/` directory for the shape, and `packages/core/src/Command/CommandInterface.php` for the contract. +- Command namespacing is by convention (`db:migrate`, `route:list`, `cache:clear`), so `rr:serve` fits. +- The shipped `.rr.yaml` must point at `vendor/marko/roadrunner/worker.php` — the worker ships inside the package and is not published into the project (decided). +- If the RoadRunner binary is missing, fail loudly with installation guidance rather than a raw "command not found" — framework principle #1. +- Consider whether `marko/devserver` should know about this (it manages `dev:up`/`dev:down` processes via `ProcessManager`). Integrating is NOT required by this task; if it looks worthwhile, note it for a follow-up issue rather than expanding scope here. +- This task shells out to a binary and writes a config file; it does not consume the worker classes. Depending on 001 alone is correct — the only contract it shares with task 004 is the `vendor/marko/roadrunner/worker.php` path, which is fixed by decision, not by code. + +### The shipped `.rr.yaml` must actually work end to end + +A config that boots the worker but serves a broken app is worse than no config. The default must include: + +- `version: "3"` and `server.command: "php vendor/marko/roadrunner/worker.php"`, `server.relay: pipes`. +- `server.env.MARKO_BASE_PATH` — task 004 resolves the application root from this first, because walking up from `__DIR__` breaks under Composer path-repository symlinks. +- **`http.static.dir: public`** (plus `http.static.forbid`). Without it every CSS, JS and image request 404s, because RoadRunner does not serve static files unless told to. The docs walkthrough in task 010 is not reproducible without this. +- **`pool.max_jobs`** — recycle each worker after N requests. This is the safety net for any leak the task 005 spike did not find, and it is the single cheapest piece of production insurance in the whole plan. Pick a conservative default and comment *why* it is there. +- **`pool.supervisor.max_worker_memory`** — kill and replace a worker that grows past a memory ceiling. Same rationale. +- `pool.num_workers` left unset so RoadRunner defaults to CPU count. + +### Command behaviour + +- `rr:serve` must not silently swallow RoadRunner's stdout/stderr — the operator needs the server log. +- Passing a custom config path through is required; the default is `.rr.yaml` in the project root. +- Do not overwrite an existing `.rr.yaml`. Scaffolding must be explicit and refuse to clobber, reporting the existing file. + +## Requirements (Test Descriptions) +- [x] `it registers an rr serve command` +- [x] `it fails with installation guidance when the roadrunner binary is missing` +- [x] `it ships a default rr yaml pointing at the packaged worker` +- [x] `it configures static file serving from the public directory` +- [x] `it configures a max jobs worker recycle limit` +- [x] `it configures a worker memory ceiling` +- [x] `it passes the base path to the worker through the server environment` +- [x] `it passes a custom config path through to roadrunner when given one` +- [x] `it refuses to overwrite an existing rr yaml` + +## Acceptance Criteria +- All requirements have passing tests +- Command follows the `#[Command]` attribute convention +- The shipped `.rr.yaml` is valid RoadRunner v3 configuration +- Code follows code standards + +## Implementation Notes + +- `Marko\Roadrunner\Command\ServeCommand` (`rr:serve`) depends on `BinaryLocatorInterface` and `ProcessRunnerInterface` (both interfaces, bound to defaults in the new `packages/roadrunner/module.php`) plus `ProjectPaths`, so tests can substitute fakes without touching a real `rr` binary or spawning a real process — this is what satisfies "do not require a RoadRunner binary to be installed for your tests to pass." +- Default `BinaryLocator` checks `/rr`, `/vendor/bin/rr`, then `command -v rr` on PATH. Default `ProcessRunner` runs via `proc_open` with descriptors `[STDIN, STDOUT, STDERR]` (not pipes), so RoadRunner's log streams straight through and is never buffered/swallowed. +- `RrYamlTemplate::render(string $basePath)` is a pure static renderer (typed consts `MAX_JOBS = 64`, `MAX_WORKER_MEMORY_MB = 128`, each with an inline comment explaining the safety-net rationale, per the task). `ServeCommand::execute()` writes this template to the resolved config path only when the file does not already exist; if it exists, the command reports it via `Output::writeLine` and leaves it untouched, then proceeds straight to `rr serve -c ` — this is the "one command to start" flow described in the task context: a second `rr:serve` run on an already-scaffolded project reuses the existing config rather than erroring. +- Config path resolution: `--config=` (relative to project base, or absolute if it starts with `/`), default `.rr.yaml` in the project root. +- Requirements 2 ("binary missing"), 3 ("ships default yaml"), 4 ("static dir"), 5 ("max jobs"), 6 ("memory ceiling"), 7 ("base path env"), and 8 ("custom config path") all passed immediately on their first RED run because `ServeCommand` and `RrYamlTemplate` were written as a single cohesive unit before the first test — the constructor's dependencies and the template's full content were needed together for requirement 1 to even compile meaningfully. Noted as over-implementation relative to strict one-test-at-a-time TDD; each requirement's test was still written and verified passing before being marked `[x]`. +- Follow-up for a separate issue (not implemented here, out of scope per task note): `marko/devserver`'s `dev:up`/`ProcessManager` could optionally manage `rr serve` as one of its supervised dev processes, similar to how it manages Docker/frontend/pubsub. Left as a future integration since this task's `rr:serve` already works standalone. +- Full local verification: `packages/roadrunner` package suite (46 tests) green, `phpcs` clean on `packages/roadrunner`, `phpstan` level 6 clean on `packages/roadrunner/src`, full monorepo `composer test` equivalent run (7027 passed, 0 failed, pre-existing 1 warning/36 risky/157 notices/2 skipped unrelated to this change). diff --git a/.claude/plans/roadrunner/009-end-to-end-test.md b/.claude/plans/roadrunner/009-end-to-end-test.md new file mode 100644 index 00000000..b597e510 --- /dev/null +++ b/.claude/plans/roadrunner/009-end-to-end-test.md @@ -0,0 +1,42 @@ +# Task 009: End-to-End Integration Test + +**Status**: pending +**Depends on**: 004, 006, 007, 008 +**Retry count**: 0 + +## Description +Prove the whole thing actually works: a real Marko application served by a real RoadRunner process, with sessions, CSRF and auth intact across sequential requests from different identities. + +## Context +- CI has no Docker or service containers (verified: `.github/workflows/ci.yml` has three jobs, none with `services:` or a container), so this test CANNOT gate every PR. Mark it `->group('integration-destructive')`, matching the existing precedent at `packages/mail-smtp/tests/Integration/StreamSocketIntegrationTest.php:26`. `composer test` excludes that group; `composer test:all` includes it. +- Skip gracefully with a clear message when the RoadRunner binary is absent, so a developer without it sees why rather than a confusing failure. +- The isolation assertions are the point of this task. Drive at least three sequential requests through one worker process — authenticated user A, then an **anonymous** request with no cookie, then user B — and assert no bleed. The anonymous request in the middle is the case that catches the confirmed `Session::$id` / `SessionGuard::$cachedUser` leak (fixed in #150 task 009); an A → B sequence alone would not. +- Reuse the fixture application from task 004a rather than building a second one. This test differs from 004a only in that requests arrive over real HTTP through a real `rr` process. +- **PSR-7 containment moved to task 001.** It is a static source scan with no dependency on anything here, and writing it first guards every task in the plan instead of only the last. Do not duplicate it. + +### Without a CI step this test never runs anywhere but a laptop + +`.github/workflows/nightly.yml` runs `composer test:all` on `ubuntu-latest` with no RoadRunner binary installed, so this test would **always skip** — the security-critical isolation assertions would never execute in CI at all. That defeats the plan's own stated mitigation ("runs locally and nightly"). + +Add a step to `nightly.yml` that installs the RoadRunner binary before `composer test:all` (`vendor/bin/rr get-binary` from `spiral/roadrunner-cli`, or download the release archive directly), and add `spiral/roadrunner-cli` to the root `require-dev` if the former. Then assert in `tests/CiWorkflowTest.php` style that the nightly workflow installs it, so a future edit cannot silently drop the step and turn the whole suite back into a skip. + +## Requirements (Test Descriptions) +- [ ] `it serves a successful http response through a real roadrunner process` +- [ ] `it preserves a session across two requests from the same client` +- [ ] `it does not leak session state between two different clients` +- [ ] `it does not leak the authenticated user into an anonymous request` +- [ ] `it does not leak the authenticated user between two different clients` +- [ ] `it sets a session cookie on the first request and not on the second` +- [ ] `it passes a csrf protected form submission` +- [ ] `it returns a five hundred and keeps serving after a request throws` +- [ ] `it skips with a clear message when the roadrunner binary is unavailable` +- [ ] `it installs the roadrunner binary in the nightly workflow` + +## Acceptance Criteria +- All requirements have passing tests +- End-to-end tests are in the `integration-destructive` group +- `composer test` stays green without a RoadRunner binary present +- `nightly.yml` installs the RoadRunner binary so `composer test:all` actually exercises this suite +- Code follows code standards + +## Implementation Notes diff --git a/.claude/plans/roadrunner/010-docs-and-readme.md b/.claude/plans/roadrunner/010-docs-and-readme.md new file mode 100644 index 00000000..eeff21dd --- /dev/null +++ b/.claude/plans/roadrunner/010-docs-and-readme.md @@ -0,0 +1,41 @@ +# Task 010: Docs Page and Package README + +**Status**: pending +**Depends on**: 009 +**Retry count**: 0 + +## Description +Write the canonical docs page and the package README. This runs last so both describe what was actually built rather than what was planned. + +## Context +- `.github/workflows/readme-package-check.yml` runs `bin/check-readme-packages.sh`, which is a **root README catalog drift check**, not a per-package README existence check. Task 001 already added the catalog row to satisfy it; this task writes `packages/roadrunner/README.md` itself, which the catalog row links to. Verify both still line up. +- READMEs are slim pointers per `docs/DOCS-STANDARDS.md`: title, install, quick example, link to the docs page. Do not duplicate the docs page into the README. +- The docs page belongs alongside the other package pages in `packages/docs-markdown/docs/packages/`. Model it on `packages/docs-markdown/docs/packages/database-readwrite.md`, which already has a "Long-Running Processes" section covering closely related ground and should be cross-linked. +- Content that must be covered: + - Installation, `rr:serve`, and the `.rr.yaml` pointing at `vendor/marko/roadrunner/worker.php` + - The `.rr.yaml` defaults and **why** they exist: `http.static.dir`, `pool.max_jobs`, `pool.supervisor.max_worker_memory`, `server.env.MARKO_BASE_PATH` + - **What is not supported and why**: `marko/sse` refuses to boot (and the config override that downgrades it to a warning, plus the fact that a `StreamingResponse` still hard-fails per request); debugbar warns; **file uploads are not supported** and throw loudly (task 002) + - **The session cookie caveat inherited from #150**: the cookie attaches to the `Response` only when it changes, so the `Response` is not a complete picture of session state on repeat requests + - The reset lifecycle: what gets reset between requests, in what order, and what that means for anyone writing a stateful singleton. Include the explicit rule — **request-scoped state in a singleton is a cross-user leak under this worker** — with the `Session` and `SessionGuard` fixes from #150 task 009 as the worked example + - **Do not write to STDOUT.** `echo`, `var_dump`, `print_r` and `dd`-style debugging corrupt the RoadRunner pipes relay. The worker buffers and discards, but developers need to know why their output vanished + - Link the task 005 spike findings page (`roadrunner-state-leaks.md`) as the record of what was investigated + - The known gap that PHPStan does not analyze this package +- Update the Package Inventory in `.claude/architecture.md` — its own checklist requires this after creating a new package. + +## Requirements (Test Descriptions) +- [ ] `it ships a readme following the package readme standards` +- [ ] `it ships a docs page for the roadrunner package` +- [ ] `it documents the unsupported packages and the reason for each` +- [ ] `it documents that file uploads are unsupported` +- [ ] `it documents the session cookie caveat` +- [ ] `it documents the stdout restriction` +- [ ] `it documents the reset lifecycle and the stateful singleton rule` +- [ ] `it lists the package in the architecture package inventory` + +## Acceptance Criteria +- All requirements have passing tests +- `bin/check-readme-packages.sh` passes and the root catalog row links to the README this task writes +- Docs page cross-links the `database-readwrite` long-running-processes section and the spike findings page +- Code follows code standards + +## Implementation Notes diff --git a/.claude/plans/roadrunner/_devils_advocate.md b/.claude/plans/roadrunner/_devils_advocate.md new file mode 100644 index 00000000..6f39c01c --- /dev/null +++ b/.claude/plans/roadrunner/_devils_advocate.md @@ -0,0 +1,206 @@ +# Devil's Advocate Review: roadrunner + +Reviewed 2026-08-28 against the actual codebase. Every claim the plan carried over from its in-session audit was re-checked; corrections are noted inline. + +## Verification of the plan's stated claims + +| Claim | Verdict | +|---|---| +| `Application::$router` public virtual property at `Application.php:82` with a throwing property hook; `public private(set) ContainerInterface $container` | **True** (hook at 82-86, container at 64) | +| `RoutingBootstrapper.php:67` registers `Router::class` via `instance()` | **True** | +| `Request` has a public constructor taking `server`, `query`, `post`, `body`, `controller`, `action` | **True** (`Request.php:14-21`) | +| Only four mutable statics across `packages/*/src` | **True** — `Debugbar::$current`, `GuidelinesWriter::$notices`, `TestCase::$registeredRoots`, `EntityCompanionStorage::$instance`. No function-level `static $x` anywhere. | +| `phpunit.xml` globs `packages/*/tests` | **True** (line 13) | +| `phpstan.neon` covers only `packages/core/src` | **True** | +| No Docker or service containers in `.github/workflows/` | **True** | +| `mail-smtp` uses `->group('integration-destructive')` at `StreamSocketIntegrationTest.php:26` | **True** | +| `queue-rabbitmq/composer.json` is a good template | **True** | +| "No core change is needed for the worker to reach the router" | **True for core.** But "this plan changes nothing outside `packages/roadrunner/`" is **false** — see C1/C2. | +| "Superglobal reads are confined to debugbar and `errors-advanced/RequestDataCollector`" | **True as stated**, but misleading: the framing missed instance state on request-scoped singletons, which is where the real leaks are. | +| `readme-package-check.yml` "enforces that every package has a README" | **False** — see C4. | +| `errors-advanced/RequestDataCollector` path | Actual path is `packages/errors-advanced/src/RequestDataCollector.php` (no `Collector/` subdirectory); lines 48-51 correct. | + +--- + +## Critical (Must fix before building) + +### C1. `Session` is a singleton that carries the previous request's session ID — a confirmed cross-user leak with no fix available inside `packages/roadrunner/` +Affects: **005, 006** (new task **005a** added) + +`packages/session-file/module.php:16-18` binds `SessionInterface` as a **singleton** to `Marko\Session\Session`. `Session::save()` (line 222) sets `started = false` but leaves `$this->id` and `$this->data` populated. `Session::start()` (line 52) then does `if ($this->id !== '') { session_id($this->id); }`. + +Even after #150 — whose task 006 seeds the ID from the inbound request cookie — the seeding only happens **when a cookie is present**. Sequence: request N is an authenticated user with a session cookie; request N+1 is an anonymous visitor with no cookie; nothing calls `setId()`; `$this->id` still holds request N's ID; `session_id()` loads request N's session. The anonymous visitor is now the previous user. + +`SessionInterface` offers no escape: `setId('')` throws `InvalidSessionIdException` because `validateId()` (line 271) requires `^[a-zA-Z0-9-]{32,128}$`. + +**Fix applied:** new task **005a** clears `$this->id` / `$this->data` / `$this->flashBag` in `Session::save()` — an FPM-neutral change, since under FPM the process dies immediately after. `_plan.md` Scope updated to allow it. + +### C2. `SessionGuard::$cachedUser` persists on a singleton guard with no non-destructive reset +Affects: **005, 006** (new task **005a**) + +`packages/authentication/module.php:25-28` marks `AuthManager` and `GuardInterface` as singletons; `AuthManager::$guards` (line 18) memoizes instances. `SessionGuard::$cachedUser` (`Guard/SessionGuard.php:24`) is set in `user()` (lines 60-75) and cleared only by `logout()`, which is destructive and unusable as a per-request reset. + +Task 006 said "isolate the authenticated user between two sequential requests" but there is no API that does it. **Fix applied:** task 005a adds a non-destructive forget on the concrete guard (not on `GuardInterface`, so `marko/testing`'s fakes stay valid), mirroring how `ReadWriteConnection::resetStickyState()` is a concrete-class method. + +### C3. The framework's global error handler writes to STDOUT, which is the RoadRunner relay +Affects: **004** + +`packages/errors-simple/module.php:18-19` — the module boot callback resolves `ErrorHandlerInterface` and calls `register()`, installing `set_exception_handler()`, `set_error_handler()` and `register_shutdown_function()` (`SimpleErrorHandler.php:146-148`). `handle()` (line 45) checks `Environment::isCli()` — **true under a RoadRunner worker** — and `echo`s the report to STDOUT. With the default `pipes` relay, STDOUT carries goridge protocol frames. One uncaught throwable corrupts the stream and kills the worker, with no useful diagnostic. + +`SimpleErrorHandler::clearOutputBuffers()` (line 64) additionally drains **all** output buffers. + +The plan had no mention of STDOUT anywhere. **Fix applied:** task 004 now requires installing a worker-safe exception handler after boot, wrapping each request in an output buffer, restoring `ob_get_level()` on the exception path, and never letting application output reach STDOUT. + +### C4. Task 001 will turn `composer test` red for every subsequent worker +Affects: **001** (and therefore every task) + +`tests/PackagingTest.php` scans `packages/` and asserts, for **every** directory: +- a `.gitattributes` that `export-ignore`s `tests/`, `.gitattributes`, `.gitignore` and `phpunit.xml`/`phpunit.xml.dist` +- a `LICENSE` (MIT, copyright `Devtomic LLC`) +- the package basename present as an option in **both** `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml` + +The plan mentioned `LICENSE` and `.gitattributes` only in passing and never mentioned the issue templates. + +Separately, `.github/workflows/readme-package-check.yml` does **not** check that a package has a README. It runs `bin/check-readme-packages.sh`, a **root README catalog drift check** that scrapes `packages//README.md` links out of the root `README.md` and fails if any non-`type: project` package lacks a row. Tasks 001 and 010 both described it wrongly. + +**Fix applied:** task 001 now enumerates all ten monorepo wiring items with acceptance criteria including a green `composer test`; task 010 corrected. + +### C5. Root `composer.json` needs four edits and a regenerated lock, not just a path repository +Affects: **001** + +The plan named only the `repositories` array. Also required: +- `require`: `"marko/roadrunner": "self.version"` — without it the package is never symlinked into `vendor/` and `Marko\Roadrunner\*` does not autoload at all. +- `autoload-dev.psr-4`: `"Marko\\Roadrunner\\Tests\\": "packages/roadrunner/tests/"` — every one of the ~95 siblings has one. +- `require-dev`: `spiral/roadrunner-http` and `nyholm/psr7`, mirroring how `php-amqplib/php-amqplib` is declared at the root for `queue-rabbitmq`. +- **`composer.lock` regenerated.** CI installs with `ramsey/composer-install@v3` (= `composer install` from the lock). New deps absent from the lock are simply not installed, and every job fails. +- `ext-sockets`: `spiral/roadrunner-worker` pulls `spiral/goridge`, which requires it. If confirmed after `composer update`, add to root `require` and add `extensions: sockets` to `setup-php` in all three `ci.yml` jobs and in `nightly.yml`. + +**Fix applied** to task 001. + +### C6. The container cannot tell you what has been resolved, so "reset every service" is unimplementable as written +Affects: **006** + +`Marko\Core\Container\Container` exposes only `get()`, `has()` and `instance()`. `has()` returns `isset($this->bindings[$id]) || class_exists($id)` (line 61) — **true for any class that exists** — and `$instances` is private with no accessor. + +Consequences: the reset list must be an explicit enumeration, and `$container->get(X)` to reset X will **instantiate** X if the request never used it (opening a database connection on every request, for example). Task 006's requirement "it resets every service identified by the spike" needed this constraint spelled out. + +**Fix applied:** task 006 documents the constraint and adds `it does not instantiate a service that the request never used`. + +--- + +## Important (Should fix before building) + +### I1. Task 005 had no harness — the pivotal task's method had no mechanism +Affects: **005** (new task **004a** added) + +"Drive sequential requests through the worker with different identities" requires a fixture Marko project that boots session and auth, plus a driver. Neither existed anywhere in the plan, and building one is itself substantial work sitting on the critical path. + +It also does not need RoadRunner: booting one `Application` and driving `Request` objects through `Router::handle()` in-process reproduces every worker state-leak condition. **Fix applied:** new task **004a** owns the fixture app and harness, depends on 001 only, and runs in parallel with 002/003/007/008. Task 005 now depends on 004a instead of 004, removing the accept loop from the leak-discovery critical path entirely. + +### I2. The spike's checklist could pass while real leaks remain +Affects: **005** + +As written it named four leads and "container singletons across ~90 packages". That is not a search, it is a sample. **Fix applied:** task 005 now requires an explicit verdict for each of: +- Every `singletons` declaration — there are 17 `module.php` files with one (`session-file`, `session-database`, `authentication`, `authorization`, `database`, `debugbar`, `inertia`, `layout`, `vite`, `docs`, `docs-markdown`, `docs-fts`, `lsp`, `mcp`, `devai`, `codeindexer`, + a fixture). +- Boot-time `Container::instance()` bindings, which are singletons in practice without a `singletons` key: eight in `Application::initialize()`, three in `RoutingBootstrapper::boot()` (lines 58-67), two in `database-readwrite/module.php:44-45`. +- Process-global PHP state no singleton audit would find: `register_shutdown_function` accumulation, `set_exception_handler` stack depth, `ob_get_level()` drift, `ini_set` drift, `session_status()` left active by a thrown request, timezone/locale, uncommitted transactions on a pooled connection, RNG seeding. +- Memory growth over several hundred requests, not two. + +Specifically flagged as a lead: `Session::configure()` (line 254) calls `session_set_save_handler($handler, true)` on **every** `start()`, and the `true` registers a shutdown function each time — unbounded growth in a long-running worker. + +### I3. `.rr.yaml` as specified serves a broken app +Affects: **008** + +The plan required only that it point at the packaged worker. Missing: `http.static.dir: public` (without it every asset 404s and the task 010 walkthrough is not reproducible), `pool.max_jobs` and `pool.supervisor.max_worker_memory` (the production backstop for any leak the spike missed — the cheapest insurance in the plan), and `server.env.MARKO_BASE_PATH`. **Fix applied.** + +### I4. `worker.php` cannot find the project root by walking up from `__DIR__` +Affects: **004** + +`vendor/marko/roadrunner/worker.php` is a **symlink** under Composer path repositories — which is exactly how this monorepo and the documented local-develop-in-a-downstream-app setup work. `dirname(__DIR__, 3)` resolves through the symlink into `packages/roadrunner` and lands in the wrong tree. **Fix applied:** task 004 specifies `MARKO_BASE_PATH` → autoloader-derived path → validated fallback, failing loudly. + +### I5. `$_SERVER` synthesis was under-specified, and `REQUEST_URI` must carry the query string +Affects: **002** + +`Request` has almost no first-class accessors — everything reads the server array. `Request::path()` (lines 52-55) strips at the first `?`, and `Marko\Inertia\Inertia:92` / `InertiaMiddleware:34` read `$request->server('REQUEST_URI')` and use it as the page URL, so dropping the query string silently breaks Inertia. **Fix applied:** task 002 now enumerates the required key set (`REQUEST_METHOD`, `REQUEST_URI` with query, `QUERY_STRING`, `SERVER_PROTOCOL`, `HTTP_HOST`, `SERVER_NAME`, `SERVER_PORT`, `HTTPS`, `REMOTE_ADDR`, `CONTENT_TYPE`, `CONTENT_LENGTH`, `HTTP_*`) and requires multi-value PSR-7 headers be joined with `, `. + +### I6. File uploads silently vanish +Affects: **002** + +`Marko\Routing\Http\Request` has no `$_FILES` concept and no files accessor, so PSR-7 `getUploadedFiles()` has nowhere to map. A dropped upload is exactly the silent failure this framework refuses. **Fix applied:** task 002 throws loudly when the PSR-7 request carries uploaded files; task 010 documents it. + +### I7. The `headerLines()` seam returns strings, not pairs +Affects: **003** + +Confirmed against `.claude/plans/response-decoration/003-header-line-emission.md`: the method is `Response::headerLines(): array` returning a `list` of complete `Name: value` lines, cookies appended as `Set-Cookie` lines in insertion order, no SAPI calls. That plan explicitly names #151 as the consumer, so the seam holds. + +But the bridge must split each line on the **first** `: ` and route multiple `Set-Cookie` lines through `withAddedHeader()`, never `withHeader()`, or every cookie but the last is dropped. The plan said "consume that seam" without noting either. **Fix applied**, plus a test for a header value containing a colon. + +### I8. `StreamingResponse` detection must not hard-depend on `marko/sse` +Affects: **001, 003** + +`Marko\Sse\StreamingResponse extends Marko\Routing\Http\Response`, so `instanceof` works — but `marko/sse` may not be installed. **Fix applied:** task 003 uses `class_exists()`-guarded detection; task 001 adds `marko/sse` to the package's `require-dev` so the test can construct a real one. + +### I9. Refusing to boot on package *presence* is the wrong granularity +Affects: **007** + +An app may have `marko/sse` installed transitively, for a retired endpoint, or for routes served by a separate FPM pool. Forcing a user to uninstall a package to start a server contradicts the framework's own stated position: *"Opinionated, not restrictive. Every 'no' comes with a 'yes, this way instead.'"* + +**Fix applied:** refuse by default (the safe default stays), but ship a config escape hatch that downgrades the refusal to a warning for a named package, read through `ConfigRepositoryInterface` from a config file in `packages/roadrunner/config/`. The refusal message must name the exact key and value. The **real** protection is task 003's per-request `StreamingResponse` failure, which has no override — the warning says so. + +### I10. `resetStickyState()` detection was described at the wrong level +Affects: **006** + +It lives on the concrete `ReadWriteConnection` (line 133), not on `ConnectionInterface`. And `database-readwrite/module.php:18-46` registers the connection only when `config('database.driver') === 'readwrite'` — so the signal is a runtime `instanceof` on the resolved `ConnectionInterface`, not "is the package installed". Plugin interception generates subclasses, so `instanceof` holds where `get_class() === ...` would not. **Fix applied.** + +### I11. The end-to-end test would never actually run in CI +Affects: **009** + +`nightly.yml` runs `composer test:all` on `ubuntu-latest` with no RoadRunner binary, so the whole suite skips. The plan's own mitigation — "runs locally and nightly" — is false as things stand, and the security-critical isolation assertions would only ever execute on a developer's machine. **Fix applied:** task 009 adds an RR binary install step to `nightly.yml` plus an assertion (in `tests/CiWorkflowTest.php` style) that a future edit cannot silently drop it. + +### I12. The isolation sequence A → B is too weak +Affects: **009** + +The confirmed leaks (C1/C2) bite hardest on an **anonymous** request following an authenticated one, because that is when nothing seeds a fresh identity. An A → B sequence where both carry cookies would pass while the bug is live. **Fix applied:** task 009 requires authenticated A → anonymous → user B. + +### I13. Serialization in the dependency chain was avoidable +Affects: **004, 005, 006, 007, 008, 009** + +Original chain was 001 → 002/003 → 004 → 005 → 006 → 009 → 010 across seven batches, with 007 and 008 needlessly behind 004. +- **007** inspects the module registry; it needs the skeleton, nothing else. Moved to depend on **001**. +- **008** shells out to a binary and writes YAML; its only contract with 004 is a fixed path. Moved to depend on **001**. +- **005** needs a request driver, not a PSR-7 worker. Moved to depend on **004a**. +- The PSR-7 containment assertion in 009 is a static source scan needing nothing. Moved to **001**, where it guards every subsequent task instead of only the last. + +New shape: **(1)** 001 → **(2)** 002, 003, 004a, 007, 008 → **(3)** 004, 005, 005a → **(4)** 006 → **(5)** 009 → **(6)** 010. Six batches, and batch 2 has five parallel workers instead of two. + +### I14. Error responses could leak exception details +Affects: **004** + +"Catch, log, return a 500" said nothing about the body. **Fix applied:** the 500 body must not contain the message or trace outside development. + +### I15. Reset should run before the request, not after +Affects: **006** + +The plan said "between requests". After-the-fact resetting means a worker killed mid-request (`pool.max_jobs` recycle, supervisor OOM kill, `SIGTERM`) can leave state that the next boot inherits from a warm pool. **Fix applied:** reset before each request, with a test asserting it still runs after a request throws. + +--- + +## Minor (Nice to address — not applied) + +- **Spike deliverable location.** The plan put it at `packages/roadrunner/docs/state-leaks.md`. No other package has a `docs/` directory, and the package `.gitattributes` export rules do not account for one. I moved it to `packages/docs-markdown/docs/packages/roadrunner-state-leaks.md` alongside the other package docs, which is a judgement call worth confirming. +- **Task 010 → 009 dependency.** Docs do not technically need the end-to-end test; 010 could depend on 006/007/008 and shave a batch. Left as-is because the "describe what was actually built" argument is sound. +- **`marko/devserver` integration.** Task 008 correctly defers it. Worth a follow-up issue: `dev:up` starting `rr serve` via `ProcessManager` is the obvious next step. +- **`CookieJarInterface` has no implementation** anywhere in `packages/*/src` (only `marko/testing`'s `FakeCookieJar`), so `SessionGuard`'s remember-me path is dead code in practice. Not this plan's problem, but it means task 009's auth assertions cannot exercise remember-me. +- **PHPStan gap.** Recorded as a decision. Given this package is the one place PSR-7 types cross a boundary and the one place a type error becomes a security bug, adding `packages/roadrunner/src` to `phpstan.neon` would cost one line. Deliberately not applied — the plan calls it a resolved decision. + +--- + +## Questions for the Team + +1. **Should `ResettableInterface` be reconsidered now?** The plan forbids it on "no pseudo-functionality" grounds, and that was right when the leak set was unknown. It is now known to include at least `Session`, `SessionGuard` and `ReadWriteConnection` — three packages, three bespoke concrete-class methods, each needing a `class_exists`-guarded `instanceof` in the worker. That is the shape of a missing interface. Still correct to defer to post-#151, or does three instances justify it now? + +2. **Should `Container` gain a way to see resolved instances?** Without one (C6), the reset list is hardcoded and any package that later adds request-scoped singleton state silently breaks worker mode with no way to detect it. A `resolvedInstances(): array` accessor would be additive and BC-safe, but it is a core change and the plan forbids those. + +3. **Is `pool.max_jobs` the honest answer to residual leak risk?** Recycling workers every N requests is what every RoadRunner deployment does, and it converts an unbounded leak into a bounded one. But it also means the spike's thoroughness matters less than the plan implies. Worth deciding explicitly whether it is a backstop or a strategy — it changes how much budget task 005 deserves. + +4. **Does `marko/sse` under RoadRunner have a real answer eventually?** RoadRunner supports streaming responses natively (`http` plugin chunked output). "Incompatible by design" is true for the current `StreamingResponse::send()` implementation, not for SSE as a concept. Worth an issue so the docs can say "not yet" rather than "never". diff --git a/.claude/plans/roadrunner/_plan.md b/.claude/plans/roadrunner/_plan.md new file mode 100644 index 00000000..7ab93d3a --- /dev/null +++ b/.claude/plans/roadrunner/_plan.md @@ -0,0 +1,123 @@ +# Plan: RoadRunner Application Server Support + +## Created +2026-08-28 + +## Status +in_progress + +## Objective +Create a `marko/roadrunner` driver package that serves a Marko application under RoadRunner — booted once, serving many requests — with a per-request reset lifecycle proven by an empirical spike rather than guessed at, and loud guard rails against packages that are unsafe in worker mode. + +## Related Issues +Closes #151 +Depends on #150 (plan `response-decoration`) + +## Discovery Notes + +> **Re-verified against the codebase on 2026-08-28.** Corrections from that pass are marked **[corrected]**. + +**No `marko/core` changes are required — but "no changes outside `packages/roadrunner/`" is false. [corrected]** `Application` already exposes a public virtual property `$router` (`packages/core/src/Application.php:82`) whose property hook throws a loud error when routing is absent, and `RoutingBootstrapper.php:67` registers `Router::class` in the container via `instance()`. `Application` also exposes `public private(set) ContainerInterface $container` (line 64). The worker loop is therefore `$app->router->handle($request)` against seams that already exist. `handleRequest()` (line 397) is not reusable in worker mode because it hardcodes `fromGlobals()` and `send()`, but nothing needs to change about it. **However**, `packages/session/` and `packages/authentication/` do need small changes — see the confirmed leaks below. **Those have been moved into #150** (plan `response-decoration`, task 009) so this plan stays purely additive; this plan consumes them. + +**The request boundary is already pure.** `Request` (`packages/routing/src/Http/Request.php:14`) is readonly with a public constructor taking plain arrays (`server`, `query`, `post`, `body`, `controller`, `action`). `Response` exposes `statusCode()`, `headers()`, `body()` separately from `send()`. `Router::handle(Request): Response` (`packages/routing/src/Router.php:38-40`) is a pure function. `Request` has **no** files concept, so PSR-7 uploads have nowhere to map — task 002 fails loudly on them. + +**Two cross-user leaks are already confirmed from source. [corrected]** The earlier audit's "very little global state" framing was too optimistic — it looked at statics and superglobals and missed instance state on request-scoped singletons: +- `SessionInterface` is a **singleton** (`packages/session-file/module.php:16-18`). `Session::save()` (line 222) leaves `$this->id` populated and `Session::start()` (line 52) does `session_id($this->id)`. After #150 the middleware seeds the ID from the inbound cookie *only when one is present*, so a request with no cookie inherits the previous request's session. `SessionInterface` has no way to clear it — `setId('')` fails `validateId()`. +- `AuthManager` and `GuardInterface` are **singletons** (`packages/authentication/module.php:25-28`) and `SessionGuard::$cachedUser` (line 24) is only cleared by the destructive `logout()`. + +**Statics and superglobals — the earlier counts hold.** Four mutable statics across `packages/*/src`: `EntityCompanionStorage::$instance`, `Debugbar::$current`, `GuidelinesWriter::$notices`, `TestCase::$registeredRoots`; only the first two matter at runtime. Superglobal reads outside `Request::fromGlobals()` are confined to debugbar (five sites, dev-only) and `errors-advanced/src/RequestDataCollector.php:48-51`, which is constructor-injectable with `?? $_SERVER` fallbacks. Everything else is boot-time env reading. The middleware layer reads zero superglobals. + +**STDOUT is the relay and the framework writes to it. [corrected]** `packages/errors-simple/module.php:18-19` registers a global exception handler at boot; `SimpleErrorHandler::handle()` (line 45) checks `Environment::isCli()` — true under a RoadRunner worker — and `echo`s the report to STDOUT, which is the goridge pipes relay. One uncaught throwable corrupts the protocol. Task 004 must install a worker-safe handler and buffer per-request output. + +**The container cannot enumerate resolved instances. [corrected]** `Container::has()` returns `isset($bindings[$id]) || class_exists($id)` (line 61) — true for any existing class — and `$instances` is private with no accessor. The reset lifecycle must work from an explicit list; there is no "reset everything resolved" option without a core change, which this plan does not make. + +**Verification is constrained by CI.** `.github/workflows/ci.yml` has no Docker or service containers, so an end-to-end RoadRunner test cannot gate every PR. The repo already has a precedent: `mail-smtp` marks its socket test `->group('integration-destructive')` (`StreamSocketIntegrationTest.php:26`), which `composer test` excludes. `phpunit.xml` globs `packages/*/tests` (line 13), so a new package's tests are auto-discovered with no phpunit config change. **But the monorepo `composer.json` needs four separate edits** (`repositories`, `require`, `require-dev`, `autoload-dev.psr-4`) plus a regenerated `composer.lock`, because CI installs via `ramsey/composer-install`. And `tests/PackagingTest.php` asserts every package has `.gitattributes`, a `Devtomic LLC` `LICENSE`, and an entry in **both** issue templates — a bare new directory turns `composer test` red for every subsequent worker. Task 001 owns all of it. + +**`readme-package-check.yml` is a root-README catalog drift check, not a per-package README check. [corrected]** `bin/check-readme-packages.sh` scrapes `packages//README.md` links out of the root `README.md` and fails on drift. The row must land in task 001; the package README file itself is task 010. + +**PHPStan now covers this package. [decision reversed]** `phpstan.neon` analyzed only `packages/core/src`. It is being extended with `packages/roadrunner/src` — this is the one package where a type error becomes a cross-user security bug, which outweighs the inconsistency of being the sole non-core package analysed. + +**Most leaks are findable without RoadRunner. [corrected]** Boot one `Application` and drive N `Request` objects through `$app->router->handle()` in-process — that reproduces every worker state-leak condition with no binary, no subprocess and no PSR-7. Task 004a builds that harness, which is why the spike no longer waits on the accept loop. + +### Resolved decisions (do not reopen) +- **Verification**: unit tests for the bridge, guard rails and reset lifecycle run in CI; the real end-to-end RoadRunner test is `->group('integration-destructive')`. +- **`worker.php` ships inside the package.** `.rr.yaml` points at `vendor/marko/roadrunner/worker.php`. Users needing custom boot behavior point their own `.rr.yaml` at their own file; no publish command in v1. +- **PHPStan is extended to `packages/roadrunner/src`** (reversing the earlier core-only decision). + +### Inherited from #150 (already decided there) +- `Response` drops the `readonly` class modifier; `with*()` methods use `clone` and return `static`. Verified on PHP 8.5.1: `clone with` does not exist in 8.5.1, and both readonly-preserving approaches fail with "Cannot modify readonly property". +- Cookies are a separate `list` collection; `headers()` keeps `array`. +- **Seam verified**: #150 task 003 (`.claude/plans/response-decoration/003-header-line-emission.md`) names the method **`Response::headerLines(): array`**, returning a `list` of complete header lines — regular headers first, then one `Set-Cookie` per cookie in insertion order, with no SAPI calls. That task explicitly states the seam exists for this worker. Task 003 consumes it by name and must not reimplement `Set-Cookie` serialization. +- #150 adds `Request::cookie()` and makes `fromGlobals()` capture `$_COOKIE` (task 006a there). The cookie parameter is added last / named-only with a default, so the bridge constructs with named arguments. +- **#150 attaches the session cookie to the `Response` only when it changes** (new / regenerated / destroyed), because always-attaching would silently disable the page cache. The `Response` is therefore NOT a complete picture of session state on a repeat request, and this worker must not assume otherwise. +- **#150 now DOES make `Session` and `SessionGuard` request-resettable.** Its task 009 clears `Session::$id`/`$data`/`$flashBag` in `save()`, gives `SessionGuard` a non-destructive way to forget its cached user, and fixes the per-request shutdown-function accumulation. Its task 008 adds `ResettableInterface` to `marko/core`; its task 010 makes `ReadWriteConnection` implement it; its task 011 adds a `Container` resolved-instances accessor. This plan CONSUMES all four and implements none of them. + +## Scope + +### In Scope +- New `marko/roadrunner` package following driver-package conventions, fully wired into the monorepo (`composer.json` x4, `composer.lock`, `.gitattributes`, `LICENSE`, issue templates, root README catalog) +- PSR-7 to Marko `Request` bridge, and Marko `Response` to PSR-7 bridge +- `worker.php` accept loop, boot-once/serve-many, with STDOUT hygiene and a worker-safe exception handler +- An in-process multi-request harness and fixture app (task 004a) that later tasks build on +- `.rr.yaml` scaffolding and an `rr:serve` CLI command +- An empirical spike enumerating what leaks across requests, committed as an artifact +- Per-request reset lifecycle wired for whatever the spike identifies +- Loud guard rails: refuse to boot with `marko/sse` (with a documented config override); warn on debugbar; reset `database-readwrite` sticky state +- Docs page and package README + +### Out of Scope +- Any change to `Response` / `Request` cookie or decoration APIs — those belong to #150 +- Any change to `marko/core` — the seams already exist +- Any change to `SessionInterface` or `GuardInterface` — the contracts stay byte-identical so `marko/testing`'s fakes keep working +- `ResettableInterface`, the `Container` accessor, and the `Session`/`SessionGuard`/`ReadWriteConnection` changes — all moved to #150; this plan calls them, it does not build them +- Adding a container API to enumerate resolved instances; the reset list stays explicit +- Refactoring debugbar's superglobal reads (dev-only; warn instead) +- Making `marko/sse` work under the worker (incompatible by design) +- File upload support (`Request` has no files concept; fail loudly and document) + +## Success Criteria +- [ ] A Marko app serves HTTP under RoadRunner with sessions, CSRF and auth intact +- [ ] An authenticated → anonymous → different-user request sequence through one worker shows zero identity bleed +- [ ] Spike findings committed, with an explicit verdict for every singleton, boot-time `instance()` binding, mutable static and process-global enumerated in task 005 +- [ ] Reset lifecycle covers everything the spike identified +- [ ] Booting with `marko/sse` installed fails loudly with an actionable message that names the config override +- [ ] No PSR-7 symbol appears anywhere outside `packages/roadrunner/` (asserted from task 001 onward) +- [ ] Nothing in the request path writes to STDOUT +- [ ] Root README catalog row exists (enforced by `readme-package-check.yml`), package README exists, and a docs page ships +- [ ] `nightly.yml` installs the RoadRunner binary so the end-to-end suite actually runs there +- [ ] `composer ci` fully green + +## Task Overview +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Package scaffolding, monorepo wiring, PSR-7 containment test | - | completed | +| 002 | PSR-7 request to Marko Request bridge | 001 | completed | +| 003 | Marko Response to PSR-7 response bridge | 001 | completed | +| 004a | In-process multi-request test harness and fixture app | 001 | completed | +| 007 | Guard rails for worker-unsafe packages | 001 | completed | +| 008 | rr:serve command and .rr.yaml scaffolding | 001 | completed | +| 004 | Worker accept loop | 002, 003 | completed | +| 005 | State-leak discovery spike | 004a | completed | +| 006 | Per-request reset lifecycle | 004, 005 | pending | +| 009 | End-to-end integration test | 004, 006, 007, 008 | pending | +| 010 | Docs page and package README | 009 | pending | + +Batches: **(1)** 001 → **(2)** 002, 003, 004a, 007, 008 → **(3)** 004, 005 → **(4)** 006 → **(5)** 009 → **(6)** 010. + +## Architecture Notes +- The worker never calls `Response::send()`. It consumes `Response::headerLines()` (the #150 seam) plus `statusCode()` and `body()`, and maps them onto a PSR-7 response. `headerLines()` returns formatted strings, so the bridge splits on the first `: ` and uses `withAddedHeader()` for `Set-Cookie`. +- PSR-7 and the `spiral/roadrunner-http` / `nyholm/psr7` dependencies are confined to this package. `marko/core` has zero PSR-7 today and that invariant must hold — task 001 asserts it in the monorepo `tests/` suite so it guards every subsequent task. +- The reset lifecycle is discovered, not designed. Task 005 drives requests through the 004a harness and observes; task 006 only wires what 005 found. This follows CLAUDE.md principle #5 ("no pseudo-functionality"). The two leaks already confirmed from source are fixed in #150 task 009, so the spike spends its budget on the unknown ones. +- Leak discovery does not need RoadRunner. The 004a harness boots one `Application` and drives `Request` objects through `Router::handle()` in-process, which is why 005 runs in parallel with the accept loop instead of behind it. +- Guard rails follow framework principle #1: refusing to boot must explain what is wrong and how to fix it — including the config key that permits the boot, because "opinionated, not restrictive" means every "no" comes with a "yes, this way instead". +- `Application::boot()` runs once, outside the accept loop. Everything inside the loop must be per-request. Reset runs *before* each request, not after, so a crashed or killed request cannot poison the next one. + +## Risks & Mitigations +- **A missed reset leaks one user's state into another user's request** — a security bug, not a glitch. Mitigation: two leaks are already confirmed and fixed in #150 task 009; task 005 is an empirical spike with a mechanically enumerated checklist (every `singletons` declaration, every boot-time `instance()` binding, every mutable static, every process-global) whose findings drive task 006; task 009 asserts isolation across an authenticated → anonymous → different-user sequence. +- **The spike passes while a slow leak remains.** Mitigation: task 005 drives several hundred requests and records the memory curve, not two requests; task 008's `.rr.yaml` ships `pool.max_jobs` and `pool.supervisor.max_worker_memory` as the production backstop for anything still missed. +- **Stray STDOUT corrupts the goridge relay.** `errors-simple` echoes to STDOUT under the CLI SAPI. Mitigation: task 004 installs a worker-safe exception handler and buffers per-request output; task 010 documents the restriction for application developers. +- **End-to-end coverage cannot gate every PR** (no Docker in CI). Mitigation: bridge, guard rails, harness, spike and reset logic all run in `composer test`; the end-to-end test is `integration-destructive`, and task 009 adds a RoadRunner binary install step to `nightly.yml` so it is actually exercised rather than perpetually skipped. +- **This plan is blocked on #150 landing first.** Mitigation: the dependency is explicit and named per task — 003 consumes `Response::headerLines()`, 006 consumes `ResettableInterface` and the `Container` accessor. Each will fail loudly at once if #150 has not landed. +- **PSR-7 leaking into core.** Mitigation: task 001 includes an assertion that no PSR-7 symbol appears outside this package. +- **A new package directory breaks `composer test` for every later worker** (`tests/PackagingTest.php`, `bin/check-readme-packages.sh`). Mitigation: task 001 lands all ten monorepo wiring items together and its acceptance criteria include a green `composer test`. +- **#150 must land first.** Mitigation: dependency is explicit; tasks 002/003 consume `Request::cookie()` and `Response::headerLines()` by name and will fail loudly if they are absent. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 73d62368..fbbe020b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -119,6 +119,7 @@ body: - queue-rabbitmq - queue-sync - ratelimiter + - roadrunner - routing - scheduler - search diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index fcafa7ff..9cc29f9a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -107,6 +107,7 @@ body: - queue-rabbitmq - queue-sync - ratelimiter + - roadrunner - routing - scheduler - search diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c83a094..c967027f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' + extensions: sockets coverage: none ini-values: memory_limit=2G @@ -47,6 +48,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' + extensions: sockets coverage: none - name: Install dependencies @@ -69,6 +71,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' + extensions: sockets coverage: none ini-values: memory_limit=2G diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5447b398..bde0ab94 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -21,6 +21,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' + extensions: sockets coverage: none ini-values: memory_limit=2G diff --git a/.gitignore b/.gitignore index 937d9750..d0d7eeab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ composer.lock vendor/ +# The RoadRunner test harness boots a real Marko application from a fixture +# project tree, which must contain a directory literally named `vendor/` for +# module discovery to find it. The unanchored `vendor/` rule above matches at +# any depth, so without this negation the fixture's modules are never committed +# and the harness silently finds zero modules on a fresh clone or in CI. +!/packages/roadrunner/tests/Fixtures/app/vendor/ + # `rr:serve` and `vendor/bin/rr get-binary` both write into the project root: # a downloaded platform-specific binary and a generated default config. Local # dev conveniences, never committed. diff --git a/README.md b/README.md index 322cd576..83eabfbe 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Marko ships as composable packages — require only what you need. Every package | [config](packages/config/README.md) | PHP-native configuration with dot-notation access | | [env](packages/env/README.md) | Environment variable loading | | [routing](packages/routing/README.md) | Attribute-based HTTP routing with conflict detection | +| [roadrunner](packages/roadrunner/README.md) | RoadRunner application server driver | | [cli](packages/cli/README.md) | Console command system with attribute-driven discovery | | [framework](packages/framework/README.md) | Full-stack metapackage for rapid setup | diff --git a/composer.json b/composer.json index fb7e3186..00ff3f4a 100644 --- a/composer.json +++ b/composer.json @@ -300,6 +300,10 @@ "type": "path", "url": "packages/ratelimiter" }, + { + "type": "path", + "url": "packages/roadrunner" + }, { "type": "path", "url": "packages/routing" @@ -379,6 +383,7 @@ "ext-gd": "*", "ext-imagick": "*", "ext-pdo": "*", + "ext-sockets": "*", "marko/admin": "self.version", "marko/admin-api": "self.version", "marko/admin-auth": "self.version", @@ -452,6 +457,7 @@ "marko/queue-rabbitmq": "self.version", "marko/queue-sync": "self.version", "marko/ratelimiter": "self.version", + "marko/roadrunner": "self.version", "marko/routing": "self.version", "marko/scheduler": "self.version", "marko/search": "self.version", @@ -477,11 +483,13 @@ "friendsofphp/php-cs-fixer": "^3.92", "guzzlehttp/guzzle": "^7.0", "marko/debugbar": "self.version", + "nyholm/psr7": "^1.8", "pestphp/pest": "^4.3", "php-amqplib/php-amqplib": "^3.0", "predis/predis": "^2.0", "rector/rector": "^2.3", "slevomat/coding-standard": "^8.26", + "spiral/roadrunner-http": "^4.1", "squizlabs/php_codesniffer": "^4.0" }, "scripts": { @@ -586,6 +594,7 @@ "Marko\\Queue\\Rabbitmq\\Tests\\": "packages/queue-rabbitmq/tests/", "Marko\\Queue\\Sync\\Tests\\": "packages/queue-sync/tests/", "Marko\\RateLimiter\\Tests\\": "packages/ratelimiter/tests/", + "Marko\\Roadrunner\\Tests\\": "packages/roadrunner/tests/", "Marko\\Routing\\Tests\\": "packages/routing/tests/", "Marko\\Scheduler\\Tests\\": "packages/scheduler/tests/", "Marko\\Search\\Tests\\": "packages/search/tests/", @@ -611,6 +620,7 @@ "packages/devai/tests/Helpers.php", "packages/inertia/tests/Helpers.php", "packages/ratelimiter/tests/Helpers.php", + "packages/roadrunner/tests/Helpers.php", "packages/security/tests/Helpers.php", "packages/session/tests/SessionFunctionShim.php" ] diff --git a/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md b/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md new file mode 100644 index 00000000..b1c678ed --- /dev/null +++ b/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md @@ -0,0 +1,220 @@ +--- +title: RoadRunner state-leak audit +description: Mechanical audit of every source of cross-request state in the monorepo, and the verdict on whether it leaks under a long-running RoadRunner worker. +--- + +Task 005's discovery spike for `marko/roadrunner`. A long-running worker keeps +one PHP process alive across many requests, so anything that survives a +request without being explicitly cleared is a candidate for leaking one +user's state into the next user's response. This document is the mechanical +audit trail: every container singleton, boot-time `instance()` binding, +mutable class static, superglobal reader and process-global PHP setting in +the monorepo, each given an explicit **Leaks: Yes** or **Leaks: No** verdict. + +**Method.** `Marko\Roadrunner\Tests\Support\InProcessRequestHarness` (task +004a) boots one `Application` against a fixture project wiring +`marko/config`, `marko/session`, `marko/session-file` and +`marko/authentication`, then drives sequential `Request` objects through +`$app->router->handle()` with interleaved identities — authenticated, +anonymous, a different session — and several hundred requests for the memory +curve. See `packages/roadrunner/tests/StateLeakSpikeTest.php`. Services from +packages the fixture does not wire (debugbar, inertia, authorization, ...) +are audited by reading the source directly; that is called out per item +below, since it could not be exercised through a live request cycle in this +spike. + +**Scope note.** `Session` and `SessionGuard` were already made +request-scoped and given `ResettableInterface` implementations in #150 task +009. They are listed below for completeness, not because this spike +discovered them. + +Task 006 reads this document as the sole source of truth for what to wire +into the worker's per-request reset. It should wire every `Leaks: Yes` item +below and nothing else. + +## 1. Container singletons (`singletons` in `module.php`) + +Seventeen `module.php` files across the monorepo declare a `singletons` key. +For each, every mutable instance property on the declared class(es) is +listed with a verdict on whether it is request-derived. + +| Package | Service | Leaks: | Mechanism / verdict | +| --- | --- | --- | --- | +| `marko/session-file` | `SessionInterface` → `Session` | **Leaks: No** (already fixed) | `Session` implements `ResettableInterface`; `reset()` clears `$id`, `$data`, `$flashBag`. Confirmed via harness: interleaved authenticated → anonymous → different-session requests never see a prior session's `visits` count once `reset()` runs between requests. Fixed in #150 task 009, not by this spike. | +| `marko/session-database` | `SessionInterface` → `Session` | **Leaks: No** (already fixed) | Same class as `session-file`; same verdict. Source read, not re-exercised (fixture only wires the file driver). | +| `marko/authentication` | `AuthManager` | **Leaks: No** | `$guards` is a `array` cache of already-constructed guard instances keyed by guard *name* ("web", "api", ...), not by request or user. The guard instances it caches are what carry per-request identity, and those are covered below. One caveat: only the guard resolved via `GuardInterface::class` (the container-registered default) is reachable through `Container::resolvedInstances(ResettableInterface::class)`; a guard resolved only via `$authManager->guard('other')` and never through the container directly would not be swept by a generic `ResettableInterface` reset loop. Not exercised in this spike (the fixture has one guard); flagged for task 006 as a design constraint, not a confirmed leak. | +| `marko/authentication` | `GuardInterface` → `SessionGuard` | **Leaks: No** (already fixed) | `SessionGuard` implements `ResettableInterface`. Confirmed via harness: interleaved authenticated → anonymous → different-session requests never see the previous request's `user=` id once `reset()` runs between requests — proven directly against the currently logged-in fixture user. Fixed in #150 task 009. | +| `marko/authorization` | `PolicyRegistry` | **Leaks: No** | `$policies` is `array`, populated only by `register()` calls at module-boot time (throws `AuthorizationException` on duplicate registration — a boot-time invariant, not a per-request write path). No request ever calls `register()`. Source read only; `marko/authorization` is not wired into the fixture app. | +| `marko/authorization` | `GateInterface` → `Gate` | **Leaks: No** | `Gate` is constructed fresh from `AuthManager`/`PolicyRegistry` inside the binding closure and holds no declared mutable properties beyond its constructor-injected collaborators (`guard`, `policyRegistry`), both already covered. Source read only. | +| `marko/codeindexer` | `IndexCache` | **Leaks: No** | `$data` is a lazily-built, file-backed index of the *codebase* (modules, routes, config keys, ...), invalidated by comparing file mtimes against a persisted `trackedPaths` set — nothing here is derived from an HTTP request. Used by MCP/LSP tooling, not the request/response cycle. Source read only. | +| `marko/codeindexer` | `ModuleWalker` | **Leaks: No** | Holds only `readonly string $rootPath`. No mutable state. | +| `marko/codeindexer` test fixture | `FooBarService` (`vendor/foo/bar/module.php`) | **Leaks: No** | A fictional class name used only to exercise `codeindexer`'s own singleton-parsing tests; never instantiated, never installed in a real app. Out of scope by construction. | +| `marko/database` | `EntityMetadataFactory` | **Leaks: No** | `$cache` is `array`, keyed and populated from static class reflection (`linkExtendersFrom()` at module `boot`), not from request data. Source read only; `marko/database` is not wired into the fixture app. | +| `marko/debugbar` | `Debugbar` | **Leaks: Yes** | `$messages`, `$openMeasures`, `$measures`, `$queries`, `$logs`, `$viewRenders` all accumulate via `record*()` calls made during request handling and are **never cleared**. Since `Debugbar` is a container singleton, every one of these arrays grows without bound for the life of the worker process across every request that touches a collector. Also: `boot()` is guarded by `$booted` so `ob_start()` (see §5) only runs once — but that single, never-closed output buffer then captures output from *every subsequent request* in the worker, not just the one active when `boot()` ran. Already flagged as worker-incompatible by `Marko\Roadrunner\GuardRails\UnsafePackageChecker::warnDebugbar()` (task 004): "a dev-only tool ... does not fit a long-running worker cleanly." Source read only — `marko/debugbar` is not wired into the fixture app and the guard rail already refuses to make it safe, only to warn. **Reset requirement**: not resettable in the `ResettableInterface` sense (the ob_start() buffer problem is architectural, not a per-request state problem) — do not enable `marko/debugbar` in a worker-served production environment, per the existing guard rail. | +| `marko/debugbar` | `DebugbarStorage` | **Leaks: No** | Only `readonly` constructor-injected collaborators (`ConfigRepositoryInterface`, `ProjectPaths`); all actual state lives in files on disk (`put()`/`get()`/`all()`/`clear()` are pure I/O), not in instance properties. | +| `marko/debugbar` | `DatabaseConnectionPlugin` | **Leaks: Yes** | `$started` is `array>` keyed by `type . ':' . md5($sql)`, pushed in `beforeQuery()`/`beforeExecute()` and popped in `afterQuery()`/`afterExecute()`. If a request throws (or the query itself throws) between the `#[Before]` and `#[After]` hooks, the pushed timestamp is never popped and the entry stays in `$started` forever — both an unbounded memory leak (one stale array entry per aborted query, keyed by SQL content so it recurs for any repeated query text) and a correctness bug: a *later*, unrelated request running the same SQL text will `array_pop()` the stale timestamp left by the aborted request and record a nonsensical (too-long or negative-looking) duration for its own query. Source read only. **Reset requirement**: clear `$started` between requests, or key entries by a per-request correlation id instead of SQL content so a leftover entry cannot corrupt a different request's timing. | +| `marko/debugbar` | `LoggerPlugin` | **Leaks: No** | Only `readonly Debugbar $debugbar` — no mutable state beyond the `Debugbar` collaborator already listed above. | +| `marko/debugbar` | `ViewPlugin` | **Leaks: Yes** | Same `$started` push/pop-by-content-hash pattern as `DatabaseConnectionPlugin`, same failure mode on a thrown render, same reset requirement. | +| `marko/docs-fts` | `DocsSearchInterface` → `FtsSearch` | **Leaks: No** | `$pdo` is a lazily-opened, read-only connection to a static on-disk SQLite index (`resources/docs.sqlite`), not request-derived — holding it open across requests is the intended behaviour (a connection pool of one), not a leak of request data. | +| `marko/docs-markdown` | `MarkdownRepository` | **Leaks: No** | Only `readonly string $docsPath`. Docs content is read from disk per call, no cached request-derived state. | +| `marko/docs` | *(no singleton classes declared — `singletons => []`)* | **Leaks: No** | Nothing to audit. | +| `marko/inertia` | `Inertia` | **Leaks: Yes** | `$shared` is `array`, written by the public `share()` API and merged into every subsequent `render()` call's props — but `Inertia` is a container singleton and nothing ever clears `$shared` between requests. A typical usage pattern (share the current authenticated user or flash data once per request, e.g. from middleware) would leave that data visible to every following request's Inertia response until the same key is overwritten, and if a later request shares a *different* key, both keys accumulate indefinitely. This is a genuine cross-request/cross-user data leak, not just a memory leak. Source read only — `marko/inertia` is not wired into the fixture app. **Reset requirement**: implement `ResettableInterface`, clearing `$shared` — the same shape as the `Session`/`SessionGuard` fix in #150 task 009. | +| `marko/inertia` | `SsrClient` | **Leaks: No** | `readonly class`; `render()` takes the page payload as a parameter and returns a result, storing nothing between calls. | +| `marko/layout` | `HandleResolver` | **Leaks: No** | No declared properties at all in the class body. | +| `marko/layout` | `LayoutResolver` | **Leaks: No** | No declared properties at all in the class body. | +| `marko/lsp` | `LspServer` | **Leaks: No** *(out of scope)* | `$initialized`/`$shuttingDown` track a stdio LSP server's own lifecycle for editor integrations — this singleton lives in a separate long-running process from any RoadRunner HTTP worker and never participates in the request/response cycle this package serves. Not applicable. | +| `marko/mcp` | *(no singleton classes declared — `singletons => []`)* | **Leaks: No** | Nothing to audit. `McpServer` itself is bound via a plain (non-singleton) closure. | +| `marko/devai` | *(no singleton classes declared — `singletons => []`)* | **Leaks: No** *(out of scope)* | CLI-only tooling (guideline generation), never boots inside an HTTP request cycle. | +| `marko/vite` | `Vite` | **Leaks: No** | Only non-promoted-readonly constructor collaborators (`ConfigRepositoryInterface`, `ProjectPaths`); asset-manifest reads are pure and re-read from disk per call, nothing cached in an instance property. | + +## 2. Boot-time `Container::instance()` bindings + +Registered once during `Application::initialize()` (or a module's `boot` +callback) rather than via the `singletons` array, but singletons in +practice — nothing ever re-registers them. + +| Binding | Registered by | Leaks: | Verdict | +| --- | --- | --- | --- | +| `ContainerInterface` | `Application::initialize()` | **Leaks: No** | The container itself; already audited above (§3 in the source, `$resolving` is a call-scoped guard cleared in a `finally`, `$instances`/`$bindings`/`$shared` are boot-time-populated and by-design persistent — see "Container resolution" below). | +| `PluginInterceptor` | `Application::initialize()` | **Leaks: No** | Wraps class generation for plugin interception; holds no per-request state (verified: constructed once from `PluginRegistry`, which is itself populated only at boot). | +| `PluginRegistry` | `Application::initialize()` | **Leaks: No** | Populated once during module registration at boot; never mutated during request handling. | +| `ProjectPaths` | `Application::initialize()` | **Leaks: No** | `readonly`, derived from the boot-time base path. | +| `EventDispatcherInterface` | `Application::initialize()` | **Leaks: No** *(not exercised — no listener state observed in this spike; the dispatcher only holds boot-time-registered observer maps, not request data)* | Registered once; observers are registered at boot from `ObserverDefinition`s, not per request. | +| `ModuleRepositoryInterface` | `Application::initialize()` | **Leaks: No** | Backed by the boot-time module list; read-only after boot. | +| `CommandRegistry` | `Application::initialize()` (registered twice under the same key, once per discovery fork) | **Leaks: No** | CLI command definitions, populated at boot; not touched by HTTP request handling. | +| `RouteCollection` | `RoutingBootstrapper::boot()` (line ~58) | **Leaks: No — confirmed via harness** | `$routes` is populated once by `discoverRoutes()` during `boot()` and never written to again; `add()` is only ever called from that one boot-time pass. The harness drove hundreds of requests across many routes with no route-table mutation. | +| `RouteMatcherInterface` → `RouteMatcher` | `RoutingBootstrapper::boot()` (line ~63) | **Leaks: No — confirmed via harness** | Wraps the immutable `RouteCollection` above; `match()` is a pure read. | +| `Router` | `RoutingBootstrapper::boot()` (line ~67) | **Leaks: No — confirmed via harness** | `readonly class`; `handle()` resolves a fresh controller instance via `$this->container->get($matched->route->controller)` on every call (confirmed directly: two calls to `container()->get(DemoController::class)` in the same booted app return `!==` instances, since `DemoController` is not declared a singleton). Nothing about handling one request's controller is retained on `Router` itself. | +| `ConnectionInterface` → `ReadWriteConnection` | `database-readwrite/module.php:44` (boot callback, only when `database.driver === 'readwrite'`) | **Leaks: Yes (uncommitted transactions), No (sticky-write flag, already fixed)** | See §5 "Open database transactions" below — the `$stickyWrite` flag is already covered by `reset()` (#150 task 009), but `reset()` does **not** roll back a transaction left open by `beginTransaction()` when a request throws before `commit()`/`rollback()`. Source read only — `marko/database-readwrite` is not wired into the fixture app. | +| `TransactionInterface` → `ReadWriteConnection` | `database-readwrite/module.php:45` | *(same instance as above)* | Same verdict as `ConnectionInterface` above — one object registered under two interface keys. | + +## 3. Mutable class statics + +Four verified across `packages/*/src`. Only two are runtime concerns for a +RoadRunner worker; the other two never execute inside a served HTTP request. + +| Static | Leaks: | Verdict | +| --- | --- | --- | +| `Marko\Debugbar\Debugbar::$current` | **Leaks: Yes** | Set in the constructor (`self::$current = $this`), read via `Debugbar::current()`/cleared via `Debugbar::forgetCurrent()`. Since `Debugbar` is only ever constructed once (a container singleton), this static simply mirrors that one instance's already-confirmed leak (§1) — nothing additional to reset here beyond resetting `Debugbar` itself, but `forgetCurrent()` exists and should be considered if `Debugbar` is ever rebuilt mid-process. | +| `Marko\DevAi\Writing\GuidelinesWriter::$notices` | **Leaks: No** *(out of scope)* | CLI-only (`devai:update` guideline generation); never runs inside a served HTTP request. | +| `Marko\Testing\TestCase::$registeredRoots` | **Leaks: No** *(out of scope)* | Test-suite bookkeeping only; irrelevant to a production worker process. | +| `Marko\Database\Entity\EntityCompanionStorage::$instance` | **Leaks: No** | Holds a single `WeakMap>`. `WeakMap` entries are automatically removed by the garbage collector once the keyed `Entity` object itself is no longer referenced — since an `Entity` built for one request goes out of scope at the end of that request (nothing else retains it), its companion-bag entry is collected too. Self-cleaning by construction; no explicit reset needed. | + +## 4. Superglobal readers outside `Request::fromGlobals()` + +Verified complete. **Under a RoadRunner worker, `$_SERVER`, `$_GET`, +`$_POST` and `$_COOKIE` are frozen at whatever the worker process's PHP CLI +entrypoint started with** — confirmed by reading +`packages/roadrunner/src/Http/Psr7RequestBridge.php`, which builds `Request` +entirely from the incoming PSR-7 message and never touches a superglobal. +This changes the nature of every finding below: it is **not** a +cross-request identity leak (the values never change between requests, so +one user's data can't bleed into another's), but a **staleness/correctness** +bug — these code paths would show the same (effectively empty, CLI-derived) +snapshot for every request forever under a worker. + +| Location | Leaks (cross-request identity): | Verdict | +| --- | --- | --- | +| `errors-advanced/src/RequestDataCollector.php:48-51` | **No** (staleness only) | Constructor accepts injectable `$server`/`$get`/`$post`/`$cookie` overrides, falling back to the superglobals only when the caller passes nothing. `PrettyHtmlFormatter` (`errors-advanced/src/PrettyHtmlFormatter.php:20`) does call `new RequestDataCollector()` with no arguments, so it *does* hit the frozen-superglobal path under a worker — the error page's "Request" section would show stale/empty data on every error, not another user's data. Not exercised in this spike (`marko/errors-advanced` not wired into the fixture app); recorded from source. | +| `debugbar/src/Debugbar.php:517` (`serverString()`) | **No** (staleness only) | Reads `$_SERVER[$key]` directly with no injectable override. Same frozen-value effect as above. Moot in practice: `marko/debugbar` is already flagged unsafe for worker mode (§1). | +| `debugbar/src/Controller/ProfilerController.php:92` (`serverHeader()`) | **No** (staleness only) | Same pattern, same package, same existing guard-rail mitigation. | +| `debugbar/src/Collectors/RequestCollector.php:18-39` | **No** (staleness only) | Reads `$_SERVER['REQUEST_METHOD']`, `$_SERVER['REQUEST_URI']`, `$_GET`, `$_POST`, and iterates `$_SERVER` for `HTTP_*` headers — all frozen under a worker. Same package, same existing mitigation. | +| `debugbar/src/Collectors/InertiaCollector.php:127` | **No** (staleness only) | Same pattern. | + +## 5. Process-global PHP state (no singleton audit finds these) + +| Item | Leaks: | Verdict | +| --- | --- | --- | +| `register_shutdown_function()` accumulation — `Session::configure()` | **Leaks: No — already fixed, confirmed via harness** | `Session.php` guards the `session_set_save_handler($this->handler, true)` call (which registers a shutdown callback as a side effect) behind a private `$handlerRegistered` flag, set once and never cleared by `reset()`. Confirmed by driving ten `/session/write` requests through the harness and asserting via reflection that `handlerRegistered` is `true` and the code path that flips it only ever runs once (the flag would prevent a second `session_set_save_handler()` call for the life of the process). | +| `register_shutdown_function()` — `SimpleErrorHandler::register()` / `AdvancedErrorHandler::register()` | **Leaks: No** | Both guard `register()` behind a `$registered` flag, and both are invoked exactly once, from a module `boot` callback (not from request handling) — confirmed by reading `errors-simple/module.php` and `errors-advanced/module.php`, whose `boot` closures call `$handler->register()` unconditionally but only run once per process (during `Application::initialize()`). | +| `set_exception_handler()` / `set_error_handler()` stack depth | **Leaks: No** | Same two handler classes as above; `register()`'s guard prevents re-registration, so the handler stack depth is fixed once at boot and never grows per request. `unregister()` exists and correctly calls `restore_exception_handler()`/`restore_error_handler()`, but nothing in the request path calls it. | +| `ob_get_level()` drift — `Debugbar::boot()` | **Leaks: Yes (architectural, already flagged)** | `boot()` calls `ob_start()` exactly once (guarded by `$booted`), but since it is never matched by a per-request `ob_end_*()`/`ob_get_clean()`, the buffer opened for the *first* request captures output from every request that follows in the same worker process. Already surfaced by `UnsafePackageChecker::warnDebugbar()` (task 004) as incompatible with a long-running worker. Confirmed clean by contrast: driving 20 requests through the harness (which does **not** wire `marko/debugbar`) shows `ob_get_level()` unchanged before and after — the drift is specific to Debugbar's `ob_start()`, not a property of request handling in general. | +| `ob_get_level()` drift — `SimpleErrorHandler::clearOutputBuffers()` | **Leaks: No** | Drains buffers down to level 0 (`while (ob_get_level() > 0) { ob_end_clean(); }`) only when rendering a fatal error page — a safety net that *reduces* drift, not a source of it. | +| `ini_set()` drift — `Session::configure()` (six calls, lines 239-244) | **Leaks: No** | All six calls re-apply the same config-derived values (`session.gc_maxlifetime`, `gc_probability`, `gc_divisor`, `use_strict_mode`, `use_cookies`, `use_only_cookies`) on every `start()`, computed fresh from `SessionConfig` each time — idempotent re-application of the same values, not drift from a previous request's mutated state. No other `ini_set()` call exists anywhere under `packages/*/src`. | +| `session_status()` left `PHP_SESSION_ACTIVE` when a request throws | **Leaks: No — already safe, confirmed via harness** | `SessionMiddleware::handle()` wraps `$next($request)` in `try { ... } finally { $this->session->save(); }`, and `Session::save()` calls `session_write_close()` unconditionally when `$this->started` is true. Confirmed directly: a new fixture route (`GET /session/throw`) writes to the session and then throws; driving it through the harness and catching the propagated exception, `session_status()` is `PHP_SESSION_NONE` immediately after — not left active. | +| Timezone / locale (`date_default_timezone_set`, `setlocale`) | **Leaks: No** | Neither function is called anywhere under `packages/*/src` in the monorepo (verified by grep). Nothing to reset. | +| Open database transactions left uncommitted by a thrown request | **Leaks: Yes** | `ReadWriteConnection::reset()` only calls `resetStickyState()` (clears `$stickyWrite`); it does **not** roll back an in-progress transaction. If a request calls `beginTransaction()` directly (not via the `transaction()` helper, which already wraps its work in `try/finally { $this->stickyWrite = false; }`) and then throws before `commit()`/`rollback()`, the underlying write connection is left with `inTransaction() === true` on a connection that is pooled across requests — the next request's writes on that connection are silently appended to the previous request's abandoned transaction. Source read only; `marko/database-readwrite` is not wired into the fixture app. **Reset requirement**: task 006 should call `rollback()` (guarded by `inTransaction()`) as part of resetting `ConnectionInterface`/`TransactionInterface` instances, in addition to the existing `resetStickyState()`. | +| `mt_srand()` / `srand()` seeding | **Leaks: No** | Neither function is called anywhere under `packages/*/src` in the monorepo (verified by grep). PHP's Mersenne Twister is auto-seeded per-process by default and nothing in this codebase re-seeds it, so there is no request-derived seed state to leak. | + +## 6. Memory growth over several hundred requests + +Driven directly (not through Pest, to get raw numbers for this document): +600 sequential `/session/write` requests through the harness, each with a +distinct session cookie, sampling `memory_get_usage(true)` every 100 +requests. + +**With `reset()` called between every request** (the shape task 006 wires): + +| Requests | Memory (RSS-equivalent) | +| --- | --- | +| 0 | 4.00 MB | +| 100 | 4.00 MB | +| 200 | 4.00 MB | +| 300 | 4.00 MB | +| 400 | 4.00 MB | +| 500 | 4.00 MB | + +**Without ever calling `reset()`** (today's unfixed-worker shape, for +comparison): + +| Requests | Memory (RSS-equivalent) | +| --- | --- | +| 0 | 4.00 MB | +| 100 | 4.00 MB | +| 200 | 4.00 MB | +| 300 | 4.00 MB | +| 400 | 4.00 MB | +| 500 | 4.00 MB | + +**Verdict: Leaks: No, flat curve — for the services this fixture wires** +(`Session`, `SessionGuard`, `AuthManager`). `AuthManager::$guards` only ever +holds one entry (the default "web" guard, cached by *name* not by request), +`Session::$data` holds a single overwritten `visits` key, and +`SessionGuard`'s cached user is a single reference reused across logins as +the same fixture user — none of these grow with request count, with or +without `reset()`. This does **not** contradict the confirmed leaks in §1 +(`Debugbar`, `Inertia`, `DatabaseConnectionPlugin`/`ViewPlugin`) — those +packages are not installed in this fixture app, so their unbounded-growth +arrays (`Debugbar::$queries`, `$messages`, ...; `Inertia::$shared`; +`DatabaseConnectionPlugin`/`ViewPlugin::$started`) are not exercised here. +Those are confirmed by source-level analysis (§1) and the general shape of +the mechanism — every push with no matching pop, on a singleton, across +however many requests reach it — makes them unbounded regardless of this +fixture's flat curve. A production app that installs those packages would +need to reproduce this same memory-curve method against them directly. + +The Pest test for this requirement (`it does not grow memory unboundedly +across several hundred requests`) drives 400 requests and asserts total +growth stays under a generous 25 MB bound — looser than the ~0 MB observed +above, so the test remains robust to environment-specific allocator +behaviour while still catching a real unbounded leak (which would blow far +past 25 MB over 400 requests). + +## 7. Anything caching a `Request` or `Response` for the process lifetime + +**Leaks: No.** Verified by grep across every `packages/*/src` directory: +no class declares an instance or static property typed `Request` or +`Response` outside a method parameter or local variable scope. The only +`static function fromGlobals(): self` on `Request` builds a fresh instance +per call and stores nothing statically. + +## Summary for task 006 + +Confirmed `Leaks: Yes` items needing a per-request reset, beyond the +already-fixed `Session`/`SessionGuard`: + +1. **`ReadWriteConnection`** — roll back an open transaction (`inTransaction()` + guarded `rollback()`) in addition to the existing `resetStickyState()`. +2. **`Inertia::$shared`** — needs a new `reset()` (implement + `ResettableInterface`) clearing `$shared`. Only relevant if + `marko/inertia` is installed. +3. **`Debugbar`**, **`DatabaseConnectionPlugin`**, **`ViewPlugin`** — not + resettable in a way that makes the package worker-safe (the `ob_start()` + buffer problem on `Debugbar` is architectural). Covered by the existing + `UnsafePackageChecker` guard rail (task 004): do not enable + `marko/debugbar` in a worker-served production environment. No + `ResettableInterface` wiring recommended for these three — the fix is + "don't run this package in a worker," already enforced. + +Everything else in this document is `Leaks: No` and should not receive +speculative reset wiring. diff --git a/packages/roadrunner/.gitattributes b/packages/roadrunner/.gitattributes new file mode 100644 index 00000000..c8df2f0b --- /dev/null +++ b/packages/roadrunner/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore + diff --git a/packages/roadrunner/LICENSE b/packages/roadrunner/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/roadrunner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/roadrunner/composer.json b/packages/roadrunner/composer.json new file mode 100644 index 00000000..368d782a --- /dev/null +++ b/packages/roadrunner/composer.json @@ -0,0 +1,40 @@ +{ + "name": "marko/roadrunner", + "description": "RoadRunner application server driver for Marko Framework", + "license": "MIT", + "type": "library", + "require": { + "php": "^8.5", + "marko/config": "self.version", + "marko/core": "self.version", + "marko/routing": "self.version", + "nyholm/psr7": "^1.8", + "psr/log": "^3.0", + "spiral/roadrunner-http": "^4.1" + }, + "require-dev": { + "marko/sse": "self.version", + "marko/testing": "self.version", + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Marko\\Roadrunner\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Roadrunner\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/config/roadrunner.php b/packages/roadrunner/config/roadrunner.php new file mode 100644 index 00000000..5889ef2a --- /dev/null +++ b/packages/roadrunner/config/roadrunner.php @@ -0,0 +1,21 @@ + [], +]; diff --git a/packages/roadrunner/module.php b/packages/roadrunner/module.php new file mode 100644 index 00000000..c6c04297 --- /dev/null +++ b/packages/roadrunner/module.php @@ -0,0 +1,15 @@ + [ + BinaryLocatorInterface::class => BinaryLocator::class, + ProcessRunnerInterface::class => ProcessRunner::class, + ], +]; diff --git a/packages/roadrunner/src/Binary/BinaryLocator.php b/packages/roadrunner/src/Binary/BinaryLocator.php new file mode 100644 index 00000000..4f62f077 --- /dev/null +++ b/packages/roadrunner/src/Binary/BinaryLocator.php @@ -0,0 +1,42 @@ +projectCandidates() as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + $fromPath = trim((string) shell_exec('command -v rr 2>/dev/null')); + + return $fromPath !== '' ? $fromPath : null; + } + + /** + * @return list + */ + private function projectCandidates(): array + { + return [ + $this->paths->base . '/rr', + $this->paths->base . '/vendor/bin/rr', + ]; + } +} diff --git a/packages/roadrunner/src/Binary/BinaryLocatorInterface.php b/packages/roadrunner/src/Binary/BinaryLocatorInterface.php new file mode 100644 index 00000000..5566b1a6 --- /dev/null +++ b/packages/roadrunner/src/Binary/BinaryLocatorInterface.php @@ -0,0 +1,16 @@ +binaryLocator->locate(); + + if ($binary === null) { + throw RoadRunnerException::binaryNotFound(); + } + + $configPath = $this->resolveConfigPath($input); + + if (file_exists($configPath)) { + $output->writeLine("Using existing config: $configPath"); + } else { + file_put_contents($configPath, RrYamlTemplate::render($this->paths->base)); + $output->writeLine("Created default config: $configPath"); + } + + $output->writeLine("Starting RoadRunner server ($binary serve -c $configPath)..."); + + return $this->processRunner->run($binary . ' serve -c ' . escapeshellarg($configPath)); + } + + private function resolveConfigPath(Input $input): string + { + $configOption = $input->getOption('config') ?? self::DEFAULT_CONFIG_FILE; + + return str_starts_with($configOption, '/') + ? $configOption + : $this->paths->base . '/' . $configOption; + } +} diff --git a/packages/roadrunner/src/Config/RrYamlTemplate.php b/packages/roadrunner/src/Config/RrYamlTemplate.php new file mode 100644 index 00000000..e9c9a8e6 --- /dev/null +++ b/packages/roadrunner/src/Config/RrYamlTemplate.php @@ -0,0 +1,61 @@ + ['$package']. " + . "This downgrades the boot-time refusal to a warning — it is only a courtesy check. The real protection stays in place: any request that still reaches this package's bridge under this worker throws per request, so you get a loud 500 on that route, not a silently truncated response.", + ); + } +} diff --git a/packages/roadrunner/src/Exceptions/UploadedFilesNotSupportedException.php b/packages/roadrunner/src/Exceptions/UploadedFilesNotSupportedException.php new file mode 100644 index 00000000..9be5b943 --- /dev/null +++ b/packages/roadrunner/src/Exceptions/UploadedFilesNotSupportedException.php @@ -0,0 +1,19 @@ + Warning messages for unsafe packages that were acknowledged rather than refused + * @throws UnsafePackageException|ConfigNotFoundException + */ + public function check(): array + { + $installedPackageNames = array_map( + fn ($module): string => $module->name, + $this->moduleRepository->all(), + ); + + $warnings = []; + + if (in_array(self::SSE_PACKAGE, $installedPackageNames, true)) { + $warnings[] = $this->checkSse(); + } + + if (in_array(self::DEBUGBAR_PACKAGE, $installedPackageNames, true)) { + $warnings[] = $this->warnDebugbar(); + } + + return $warnings; + } + + /** + * @throws UnsafePackageException|ConfigNotFoundException + */ + private function checkSse(): string + { + $acknowledged = $this->configRepository->getArray(self::ACKNOWLEDGED_UNSAFE_PACKAGES_CONFIG_KEY); + + if (!in_array(self::SSE_PACKAGE, $acknowledged, true)) { + throw UnsafePackageException::incompatibleWithWorkerMode( + package: self::SSE_PACKAGE, + reason: 'it streams a response for the life of the connection via ob_end_flush()/flush(), which is incompatible with a request/response worker that must return to the accept loop', + configKey: self::ACKNOWLEDGED_UNSAFE_PACKAGES_CONFIG_KEY, + ); + } + + return sprintf( + "Package '%s' was acknowledged via config key '%s' and is allowed to boot, but it remains incompatible with a long-running worker: it streams a response for the life of the connection via ob_end_flush()/flush(). " + . 'The boot-time check is only a courtesy — the real protection is the per-request bridge, which throws when a StreamingResponse reaches it under this worker. Expect a loud 500 on any SSE route, not a silently truncated stream.', + self::SSE_PACKAGE, + self::ACKNOWLEDGED_UNSAFE_PACKAGES_CONFIG_KEY, + ); + } + + private function warnDebugbar(): string + { + return sprintf( + "Package '%s' is installed. It is a dev-only tool that reads \$_SERVER directly and calls ob_start() once at boot, which does not fit a long-running worker cleanly. Booting anyway — do not enable it in a worker-served production environment.", + self::DEBUGBAR_PACKAGE, + ); + } +} diff --git a/packages/roadrunner/src/Http/Psr7RequestBridge.php b/packages/roadrunner/src/Http/Psr7RequestBridge.php new file mode 100644 index 00000000..6e6673e4 --- /dev/null +++ b/packages/roadrunner/src/Http/Psr7RequestBridge.php @@ -0,0 +1,106 @@ + + */ + private const array FORM_ENCODED_BODY_METHODS = ['PUT', 'PATCH', 'DELETE']; + + /** + * @throws UploadedFilesNotSupportedException + */ + public function bridge( + ServerRequestInterface $psr7Request, + ): Request { + if ($psr7Request->getUploadedFiles() !== []) { + throw UploadedFilesNotSupportedException::whenBridgingRequest(); + } + + $server = $this->buildServer($psr7Request); + $body = (string) $psr7Request->getBody(); + + return new Request( + server: $server, + query: $psr7Request->getQueryParams(), + post: $this->resolvePost($psr7Request, $server, $body), + body: $body, + cookies: $psr7Request->getCookieParams(), + ); + } + + /** + * @return array + */ + private function buildServer( + ServerRequestInterface $psr7Request, + ): array { + $uri = $psr7Request->getUri(); + $path = $uri->getPath() !== '' ? $uri->getPath() : '/'; + $query = $uri->getQuery(); + + $server = [ + 'REQUEST_METHOD' => $psr7Request->getMethod(), + 'REQUEST_URI' => $query !== '' ? "$path?$query" : $path, + 'QUERY_STRING' => $query, + ]; + + if ($uri->getScheme() === 'https') { + $server['HTTPS'] = 'on'; + } + + $remoteAddr = $psr7Request->getServerParams()['REMOTE_ADDR'] ?? null; + if ($remoteAddr !== null) { + $server['REMOTE_ADDR'] = (string) $remoteAddr; + } + + foreach ($psr7Request->getHeaders() as $name => $values) { + $normalized = strtoupper(str_replace('-', '_', $name)); + $value = implode(', ', $values); + + if ($normalized === 'CONTENT_TYPE' || $normalized === 'CONTENT_LENGTH') { + $server[$normalized] = $value; + continue; + } + + $server['HTTP_' . $normalized] = $value; + } + + return $server; + } + + /** + * PHP does not populate a parsed body for PUT/PATCH/DELETE requests carrying + * a form-urlencoded body, mirroring Request::fromGlobals()'s equivalent handling. + * + * @param array $server + * + * @return array + */ + private function resolvePost( + ServerRequestInterface $psr7Request, + array $server, + string $body, + ): array { + $parsedBody = $psr7Request->getParsedBody(); + $post = is_array($parsedBody) ? $parsedBody : []; + + $method = strtoupper($psr7Request->getMethod()); + if ($post === [] && $body !== '' && in_array($method, self::FORM_ENCODED_BODY_METHODS, true)) { + $contentType = $server['CONTENT_TYPE'] ?? ''; + if (str_contains($contentType, 'application/x-www-form-urlencoded')) { + parse_str($body, $post); + } + } + + return $post; + } +} diff --git a/packages/roadrunner/src/Http/Psr7ResponseBridge.php b/packages/roadrunner/src/Http/Psr7ResponseBridge.php new file mode 100644 index 00000000..6ba36084 --- /dev/null +++ b/packages/roadrunner/src/Http/Psr7ResponseBridge.php @@ -0,0 +1,50 @@ +isStreamingResponse($response)) { + throw StreamingResponseException::unsupported($response::class); + } + + $psr7Response = new Psr7Response( + status: $response->statusCode(), + body: $response->body(), + ); + + foreach ($response->headerLines() as $line) { + [$name, $value] = explode(': ', $line, 2); + + $psr7Response = $name === 'Set-Cookie' + ? $psr7Response->withAddedHeader($name, $value) + : $psr7Response->withHeader($name, $value); + } + + return $psr7Response; + } + + private function isStreamingResponse(Response $response): bool + { + return class_exists($this->streamingResponseClass) + && is_a($response, $this->streamingResponseClass); + } +} diff --git a/packages/roadrunner/src/Process/ProcessRunner.php b/packages/roadrunner/src/Process/ProcessRunner.php new file mode 100644 index 00000000..08789f20 --- /dev/null +++ b/packages/roadrunner/src/Process/ProcessRunner.php @@ -0,0 +1,29 @@ + STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $process = proc_open($command, $descriptors, $pipes); + + if (!is_resource($process)) { + return 1; + } + + return proc_close($process); + } +} diff --git a/packages/roadrunner/src/Process/ProcessRunnerInterface.php b/packages/roadrunner/src/Process/ProcessRunnerInterface.php new file mode 100644 index 00000000..25bd48ba --- /dev/null +++ b/packages/roadrunner/src/Process/ProcessRunnerInterface.php @@ -0,0 +1,16 @@ + */ + private const array REQUIRED_DIRECTORIES = ['vendor', 'app', 'modules']; + + public function __construct( + private ComposerAutoloaderLocatorInterface $autoloaderLocator, + ) {} + + /** + * @throws BasePathNotResolvableException + */ + public function resolve(): string + { + $fromEnvironment = $this->fromEnvironment(); + if ($fromEnvironment !== null) { + return $this->validated($fromEnvironment, sprintf('the %s environment variable', self::ENV_VAR)); + } + + $fromAutoloader = $this->fromAutoloader(); + if ($fromAutoloader !== null) { + return $this->validated($fromAutoloader, "the loaded Composer autoloader's own path"); + } + + return $this->validated((string) getcwd(), 'the current working directory'); + } + + private function fromEnvironment(): ?string + { + $value = getenv(self::ENV_VAR); + + return $value === false || $value === '' ? null : $value; + } + + private function fromAutoloader(): ?string + { + $classLoaderFile = $this->autoloaderLocator->locate(); + + // vendor/composer/ClassLoader.php -> vendor/composer -> vendor -> project root + return $classLoaderFile === null ? null : dirname($classLoaderFile, 3); + } + + /** + * @throws BasePathNotResolvableException + */ + private function validated( + string $path, + string $source, + ): string { + $path = rtrim($path, '/'); + + foreach (self::REQUIRED_DIRECTORIES as $directory) { + if ($path === '' || !is_dir("$path/$directory")) { + throw BasePathNotResolvableException::whenDirectoryMissing($path, $directory, $source); + } + } + + return $path; + } +} diff --git a/packages/roadrunner/src/Worker/ComposerAutoloaderLocatorInterface.php b/packages/roadrunner/src/Worker/ComposerAutoloaderLocatorInterface.php new file mode 100644 index 00000000..9e200c46 --- /dev/null +++ b/packages/roadrunner/src/Worker/ComposerAutoloaderLocatorInterface.php @@ -0,0 +1,22 @@ +getFileName(); + + return $file === false ? null : $file; + } + + return null; + } +} diff --git a/packages/roadrunner/src/Worker/WorkerLogger.php b/packages/roadrunner/src/Worker/WorkerLogger.php new file mode 100644 index 00000000..6c641be2 --- /dev/null +++ b/packages/roadrunner/src/Worker/WorkerLogger.php @@ -0,0 +1,41 @@ +getMessage(), + $throwable->getFile(), + $throwable->getLine(), + ); + + if ($this->logger !== null) { + $this->logger->error($message, ['exception' => $throwable]); + + return; + } + + fwrite(STDERR, $message . PHP_EOL); + } +} diff --git a/packages/roadrunner/src/Worker/WorkerRequestHandler.php b/packages/roadrunner/src/Worker/WorkerRequestHandler.php new file mode 100644 index 00000000..6a9729a1 --- /dev/null +++ b/packages/roadrunner/src/Worker/WorkerRequestHandler.php @@ -0,0 +1,79 @@ +psr7Worker->waitRequest()) !== null) { + $this->psr7Worker->respond($this->handleOne($psr7Request)); + } + } + + private function handleOne( + ServerRequestInterface $psr7Request, + ): ResponseInterface { + $outputBufferLevel = ob_get_level(); + ob_start(); + + try { + $request = $this->requestBridge->bridge($psr7Request); + $response = $this->router->handle($request); + + return $this->responseBridge->bridge($response); + } catch (Throwable $throwable) { + // Intentional catch-all: one bad request must never kill a + // long-running worker. Log it, answer with a 500, keep serving. + $this->logger->error($throwable); + + return $this->errorResponse($throwable); + } finally { + while (ob_get_level() > $outputBufferLevel) { + ob_end_clean(); + } + } + } + + private function errorResponse( + Throwable $throwable, + ): ResponseInterface { + $body = $this->development + ? sprintf("%s: %s\n\n%s", $throwable::class, $throwable->getMessage(), $throwable->getTraceAsString()) + : self::PRODUCTION_ERROR_BODY; + + return new Psr7Response(status: 500, body: $body); + } +} diff --git a/packages/roadrunner/src/Worker/WorkerSafeExceptionHandler.php b/packages/roadrunner/src/Worker/WorkerSafeExceptionHandler.php new file mode 100644 index 00000000..589c5350 --- /dev/null +++ b/packages/roadrunner/src/Worker/WorkerSafeExceptionHandler.php @@ -0,0 +1,34 @@ +handle(...)); + } + + public function handle( + Throwable $throwable, + ): void { + $this->logger->error($throwable); + } +} diff --git a/packages/roadrunner/tests/Binary/BinaryLocatorTest.php b/packages/roadrunner/tests/Binary/BinaryLocatorTest.php new file mode 100644 index 00000000..aec6a492 --- /dev/null +++ b/packages/roadrunner/tests/Binary/BinaryLocatorTest.php @@ -0,0 +1,44 @@ +locate())->toBe($binaryPath); + + unlink($binaryPath); + rmdir($tempDir . '/vendor/bin'); + rmdir($tempDir . '/vendor'); + rmdir($tempDir); + }); + + it('ignores a non executable file in vendor bin', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_rr_binary_' . bin2hex(random_bytes(8)); + mkdir($tempDir . '/vendor/bin', 0755, true); + $binaryPath = $tempDir . '/vendor/bin/rr'; + file_put_contents($binaryPath, 'not executable'); + chmod($binaryPath, 0644); + + $locator = new BinaryLocator(new ProjectPaths($tempDir)); + + expect($locator->locate())->not->toBe($binaryPath); + + unlink($binaryPath); + rmdir($tempDir . '/vendor/bin'); + rmdir($tempDir . '/vendor'); + rmdir($tempDir); + }); +}); diff --git a/packages/roadrunner/tests/Command/FakeBinaryLocator.php b/packages/roadrunner/tests/Command/FakeBinaryLocator.php new file mode 100644 index 00000000..d252f491 --- /dev/null +++ b/packages/roadrunner/tests/Command/FakeBinaryLocator.php @@ -0,0 +1,19 @@ +path; + } +} diff --git a/packages/roadrunner/tests/Command/FakeProcessRunner.php b/packages/roadrunner/tests/Command/FakeProcessRunner.php new file mode 100644 index 00000000..8e4d85d2 --- /dev/null +++ b/packages/roadrunner/tests/Command/FakeProcessRunner.php @@ -0,0 +1,19 @@ +lastCommand = $command; + + return 0; + } +} diff --git a/packages/roadrunner/tests/Command/Helpers.php b/packages/roadrunner/tests/Command/Helpers.php new file mode 100644 index 00000000..3223a717 --- /dev/null +++ b/packages/roadrunner/tests/Command/Helpers.php @@ -0,0 +1,68 @@ + $stream, + 'output' => new Output($stream), + ]; + } + + public static function tempProjectDir(): string + { + $dir = sys_get_temp_dir() . '/marko_rr_serve_' . bin2hex(random_bytes(8)); + mkdir($dir, 0755, true); + + return $dir; + } + + public static function removeTempProjectDir(string $dir): void + { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + unlink($dir . '/' . $entry); + } + + rmdir($dir); + } + + public static function serveCommand( + ?BinaryLocatorInterface $binaryLocator = null, + ?ProcessRunnerInterface $processRunner = null, + ?ProjectPaths $paths = null, + ): ServeCommand { + return new ServeCommand( + binaryLocator: $binaryLocator ?? new FakeBinaryLocator('/usr/local/bin/rr'), + processRunner: $processRunner ?? new FakeProcessRunner(), + paths: $paths ?? new ProjectPaths(self::tempProjectDir()), + ); + } +} diff --git a/packages/roadrunner/tests/Command/ServeCommandTest.php b/packages/roadrunner/tests/Command/ServeCommandTest.php new file mode 100644 index 00000000..12bc17ea --- /dev/null +++ b/packages/roadrunner/tests/Command/ServeCommandTest.php @@ -0,0 +1,74 @@ +getAttributes(Command::class); + + expect($attributes)->toHaveCount(1) + ->and($attributes[0]->newInstance()->name)->toBe('rr:serve') + ->and($reflection->implementsInterface(CommandInterface::class))->toBeTrue(); + }); + + it('fails with installation guidance when the roadrunner binary is missing', function (): void { + $tempDir = Helpers::tempProjectDir(); + $command = Helpers::serveCommand( + binaryLocator: new FakeBinaryLocator(null), + paths: new ProjectPaths($tempDir), + ); + + expect(fn () => $command->execute(new Input(['marko', 'rr:serve']), Helpers::output())) + ->toThrow(RoadRunnerException::class, 'RoadRunner binary not found.'); + + $exception = RoadRunnerException::binaryNotFound(); + + expect($exception->getSuggestion())->toContain('roadrunner.dev'); + + Helpers::removeTempProjectDir($tempDir); + }); + + it('passes a custom config path through to roadrunner when given one', function (): void { + $tempDir = Helpers::tempProjectDir(); + $processRunner = new FakeProcessRunner(); + $command = Helpers::serveCommand( + processRunner: $processRunner, + paths: new ProjectPaths($tempDir), + ); + + $command->execute(new Input(['marko', 'rr:serve', '--config=custom.rr.yaml']), Helpers::output()); + + expect($processRunner->lastCommand)->toContain('custom.rr.yaml') + ->and(file_exists($tempDir . '/custom.rr.yaml'))->toBeTrue() + ->and(file_exists($tempDir . '/.rr.yaml'))->toBeFalse(); + + Helpers::removeTempProjectDir($tempDir); + }); + + it('refuses to overwrite an existing rr yaml', function (): void { + $tempDir = Helpers::tempProjectDir(); + $existingContents = "# hand-tuned config, do not touch\n"; + file_put_contents($tempDir . '/.rr.yaml', $existingContents); + ['stream' => $stream, 'output' => $output] = Helpers::outputStream(); + $command = Helpers::serveCommand(paths: new ProjectPaths($tempDir)); + + $command->execute(new Input(['marko', 'rr:serve']), $output); + rewind($stream); + + expect(file_get_contents($tempDir . '/.rr.yaml'))->toBe($existingContents) + ->and(stream_get_contents($stream))->toContain("Using existing config: $tempDir/.rr.yaml"); + + Helpers::removeTempProjectDir($tempDir); + }); +}); diff --git a/packages/roadrunner/tests/ComposerConfigurationTest.php b/packages/roadrunner/tests/ComposerConfigurationTest.php new file mode 100644 index 00000000..22cd5215 --- /dev/null +++ b/packages/roadrunner/tests/ComposerConfigurationTest.php @@ -0,0 +1,52 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toBeNull() + ->and($composer['name'])->toBe('marko/roadrunner'); + }); + + it('declares no version key in composer json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('version'); + }); + + it('declares marko interdependencies using self dot version', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/core') + ->and($composer['require']['marko/core'])->toBe('self.version') + ->and($composer['require'])->toHaveKey('marko/routing') + ->and($composer['require']['marko/routing'])->toBe('self.version') + ->and($composer['require-dev'])->toHaveKey('marko/sse') + ->and($composer['require-dev']['marko/sse'])->toBe('self.version'); + }); + + it('registers the package as a marko module in composer extra', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['extra']['marko']['module'])->toBeTrue(); + }); + + it('autoloads the package namespace from the src directory', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload']['psr-4'])->toHaveKey('Marko\\Roadrunner\\') + ->and($composer['autoload']['psr-4']['Marko\\Roadrunner\\'])->toBe('src/'); + }); +}); diff --git a/packages/roadrunner/tests/Config/RrYamlTemplateTest.php b/packages/roadrunner/tests/Config/RrYamlTemplateTest.php new file mode 100644 index 00000000..b4df5182 --- /dev/null +++ b/packages/roadrunner/tests/Config/RrYamlTemplateTest.php @@ -0,0 +1,46 @@ +toContain('version: "3"') + ->and($yaml)->toContain('command: "php vendor/marko/roadrunner/worker.php"') + ->and($yaml)->toContain('relay: pipes'); + }); + + it('configures static file serving from the public directory', function (): void { + $yaml = RrYamlTemplate::render('/app'); + + expect($yaml)->toContain('static:') + ->and($yaml)->toContain('dir: public') + ->and($yaml)->toContain('forbid:'); + }); + + it('configures a max jobs worker recycle limit', function (): void { + $yaml = RrYamlTemplate::render('/app'); + + expect($yaml)->toContain('max_jobs: 64') + ->and($yaml)->toContain('# max_jobs recycles each worker after N requests'); + }); + + it('configures a worker memory ceiling', function (): void { + $yaml = RrYamlTemplate::render('/app'); + + expect($yaml)->toContain('max_worker_memory: 128') + ->and($yaml)->toContain('# Kill and replace a worker once it grows past this memory ceiling'); + }); + + it('passes the base path to the worker through the server environment', function (): void { + $yaml = RrYamlTemplate::render('/srv/my-app'); + + expect($yaml)->toContain('env:') + ->and($yaml)->toContain('MARKO_BASE_PATH: "/srv/my-app"'); + }); +}); diff --git a/packages/roadrunner/tests/Fixtures/app/app/demo/composer.json b/packages/roadrunner/tests/Fixtures/app/app/demo/composer.json new file mode 100644 index 00000000..d1a10fc7 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/app/demo/composer.json @@ -0,0 +1,16 @@ +{ + "name": "fixture/demo", + "description": "Fixture app module exercising session and authentication state for the in-process request harness", + "type": "marko-module", + "license": "MIT", + "autoload": { + "psr-4": { + "Marko\\Roadrunner\\Tests\\Fixtures\\Demo\\": "src/" + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/app/demo/module.php b/packages/roadrunner/tests/Fixtures/app/app/demo/module.php new file mode 100644 index 00000000..78c08b9e --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/app/demo/module.php @@ -0,0 +1,15 @@ + [ + UserProviderInterface::class => static fn (): UserProviderInterface => new FakeUserProvider( + users: [1 => new FakeAuthenticatable(id: 1)], + ), + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php b/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php new file mode 100644 index 00000000..efb0842b --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php @@ -0,0 +1,77 @@ +session->get('visits', 0) + 1; + $this->session->set('visits', $visits); + $this->guard->loginById(self::FIXTURE_USER_ID); + + return new Response($this->describeState($visits)); + } + + /** + * @throws SessionNotStartedException|RandomException + */ + #[Get('/session/read')] + public function read(): Response + { + $visits = (int) $this->session->get('visits', 0); + + return new Response($this->describeState($visits)); + } + + /** + * Writes to the session, then throws before the request completes + * normally — exercises whether the session is left PHP_SESSION_ACTIVE + * for the next request when a controller never reaches save(). + * + * @throws SessionNotStartedException|RandomException|RuntimeException + */ + #[Get('/session/throw')] + public function throwAfterSessionWrite(): Response + { + $this->session->set('visits', 999); + + throw new RuntimeException('Simulated failure after session write, before normal completion'); + } + + private function describeState( + int $visits, + ): string { + $userId = $this->guard->id() ?? 'guest'; + + return "session={$this->session->getId()};visits=$visits;user=$userId"; + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/config/authentication.php b/packages/roadrunner/tests/Fixtures/app/config/authentication.php new file mode 100644 index 00000000..91cedf62 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/config/authentication.php @@ -0,0 +1,31 @@ + [ + 'guard' => 'session', + 'provider' => 'users', + ], + 'guards' => [ + 'session' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'array', + ], + ], + 'password' => [ + 'driver' => 'bcrypt', + 'bcrypt' => [ + 'cost' => 4, + ], + ], + 'remember' => [ + 'expiration' => 43200, + 'cookie' => 'remember_token', + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/config/session.php b/packages/roadrunner/tests/Fixtures/app/config/session.php new file mode 100644 index 00000000..de3e1d26 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/config/session.php @@ -0,0 +1,25 @@ + 'file', + 'lifetime' => 120, + 'expire_on_close' => false, + 'path' => sys_get_temp_dir() . '/marko-roadrunner-harness/' . getmypid() . '/sessions', + + 'cookie' => [ + 'name' => 'marko_session', + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => true, + 'samesite' => 'lax', + ], + + 'gc_probability' => 0, + 'gc_divisor' => 100, +]; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/composer.json new file mode 100644 index 00000000..b438069a --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/composer.json @@ -0,0 +1,14 @@ +{ + "name": "marko/authentication", + "description": "Fixture stub for marko/authentication used by the in-process request harness", + "type": "marko-module", + "license": "MIT", + "require": { + "marko/session": "self.version" + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php new file mode 100644 index 00000000..a6a9a7f9 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/authentication/module.php @@ -0,0 +1,31 @@ + [ + PasswordHasherInterface::class => function (ContainerInterface $container): PasswordHasherInterface { + $config = $container->get(AuthConfig::class); + + return new BcryptPasswordHasher( + cost: $config->bcryptCost(), + ); + }, + GuardInterface::class => function (ContainerInterface $container): GuardInterface { + return $container->get(AuthManager::class)->guard(); + }, + ], + 'singletons' => [ + AuthManager::class, + GuardInterface::class, + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/composer.json new file mode 100644 index 00000000..96e7155f --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/composer.json @@ -0,0 +1,11 @@ +{ + "name": "marko/config", + "description": "Fixture stub for marko/config used by the in-process request harness", + "type": "marko-module", + "license": "MIT", + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/module.php new file mode 100644 index 00000000..844c0609 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/config/module.php @@ -0,0 +1,31 @@ + [ + ConfigRepositoryInterface::class => static function (ContainerInterface $container): ConfigRepositoryInterface { + $provider = $container->get(ConfigServiceProvider::class); + $modules = $container->get(ModuleRepositoryInterface::class); + $paths = $container->get(ProjectPaths::class); + + $modulePaths = array_map( + fn ($module) => $module->path, + $modules->all(), + ); + + return $provider->createRepository( + modulePaths: $modulePaths, + rootConfigPath: $paths->config, + ); + }, + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/composer.json new file mode 100644 index 00000000..b5a82ae9 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/composer.json @@ -0,0 +1,14 @@ +{ + "name": "marko/session-file", + "description": "Fixture stub for marko/session-file used by the in-process request harness", + "type": "marko-module", + "license": "MIT", + "require": { + "marko/session": "self.version" + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/module.php new file mode 100644 index 00000000..711b812f --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session-file/module.php @@ -0,0 +1,25 @@ + [ + SessionHandlerInterface::class => FileSessionHandler::class, + ], + 'singletons' => [ + SessionInterface::class => Session::class, + ], + 'globalMiddleware' => [ + SessionMiddleware::class, + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/composer.json new file mode 100644 index 00000000..897e7cc4 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/composer.json @@ -0,0 +1,14 @@ +{ + "name": "marko/session", + "description": "Fixture stub for marko/session used by the in-process request harness", + "type": "marko-module", + "license": "MIT", + "require": { + "marko/config": "self.version" + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/module.php new file mode 100644 index 00000000..3b5abfd8 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/session/module.php @@ -0,0 +1,8 @@ + [], + ]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + + expect(fn () => $checker->check())->toThrow(UnsafePackageException::class); + }); + + it('explains why sse cannot work in worker mode when refusing', function (): void { + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/sse', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([ + 'roadrunner.acknowledged_unsafe_packages' => [], + ]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $exception = catchThrowable(fn () => $checker->check()); + + expect($exception)->toBeInstanceOf(UnsafePackageException::class) + ->and($exception->getMessage())->toContain('marko/sse') + ->and($exception->getMessage())->toContain('worker') + ->and($exception->getMessage())->toContain('stream'); + }); + + it('names the config override in the refusal message', function (): void { + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/sse', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([ + 'roadrunner.acknowledged_unsafe_packages' => [], + ]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $exception = catchThrowable(fn () => $checker->check()); + + expect($exception)->toBeInstanceOf(UnsafePackageException::class) + ->and($exception->getSuggestion())->toContain('roadrunner.acknowledged_unsafe_packages') + ->and($exception->getSuggestion())->toContain('marko/sse'); + }); + + it('boots with a warning when the sse package is explicitly acknowledged in config', function (): void { + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/sse', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([ + 'roadrunner.acknowledged_unsafe_packages' => ['marko/sse'], + ]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $warnings = $checker->check(); + + expect($warnings)->toHaveCount(1) + ->and($warnings[0])->toContain('marko/sse') + ->and($warnings[0])->toContain('500'); + }); + + it('warns but continues when the debugbar package is installed', function (): void { + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/debugbar', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([ + 'roadrunner.acknowledged_unsafe_packages' => [], + ]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $warnings = $checker->check(); + + expect($warnings)->toHaveCount(1) + ->and($warnings[0])->toContain('marko/debugbar'); + }); + + it('boots without complaint when no unsafe package is installed', function (): void { + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/core', version: '1.0.0'), + new ModuleManifest(name: 'marko/routing', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $warnings = $checker->check(); + + expect($warnings)->toBeEmpty(); + }); + + it('reads installed modules from the module repository rather than class existence', function (): void { + // marko/sse is a real require-dev dependency of this package (Marko\Sse\StreamingResponse + // is autoloadable right now), but the module repository below does not report it as an + // installed module. If the checker fell back to class_exists(), it would still refuse to + // boot here; because it consults the repository only, it must not. + expect(class_exists(StreamingResponse::class))->toBeTrue(); + + $moduleRepository = createModuleRepository([ + new ModuleManifest(name: 'marko/core', version: '1.0.0'), + ]); + $configRepository = new FakeConfigRepository([]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $warnings = $checker->check(); + + expect($warnings)->toBeEmpty(); + }); + + it('runs guard rail checks once rather than per request', function (): void { + $moduleRepositoryCalls = 0; + $moduleRepository = createModuleRepository( + [new ModuleManifest(name: 'marko/debugbar', version: '1.0.0')], + onAll: function () use (&$moduleRepositoryCalls): void { + $moduleRepositoryCalls++; + }, + ); + $configRepository = new FakeConfigRepository([]); + $checker = new UnsafePackageChecker($moduleRepository, $configRepository); + $checker->check(); + + expect($moduleRepositoryCalls)->toBe(1); + }); +}); diff --git a/packages/roadrunner/tests/Helpers.php b/packages/roadrunner/tests/Helpers.php new file mode 100644 index 00000000..c527e5a8 --- /dev/null +++ b/packages/roadrunner/tests/Helpers.php @@ -0,0 +1,149 @@ + $modules + */ +function createModuleRepository( + array $modules, + ?Closure $onAll = null, +): ModuleRepositoryInterface { + return new class ($modules, $onAll) implements ModuleRepositoryInterface + { + public function __construct( + private readonly array $modules, + private readonly ?Closure $onAll, + ) {} + + public function all(): array + { + if ($this->onAll !== null) { + ($this->onAll)(); + } + + return $this->modules; + } + }; +} + +/** + * Invoke a callback and return the Throwable it raises, or null if it + * completes without throwing. Used to assert on exception content without + * a try/catch block in each test. + */ +function catchThrowable( + Closure $callback, +): ?Throwable { + try { + $callback(); + } catch (Throwable $throwable) { + return $throwable; + } + + return null; +} + +/** + * Absolute path to the fixture Marko project consumed by + * InProcessRequestHarness: a real vendor/modules/app tree wiring + * marko/session, marko/session-file and marko/authentication. + */ +function inProcessHarnessFixturePath(): string +{ + return __DIR__ . '/Fixtures/app'; +} + +/** + * The session cookie name configured in the fixture app's config/session.php. + */ +function inProcessHarnessSessionCookieName(): string +{ + return 'marko_session'; +} + +/** + * A syntactically valid session id (Session::validateId requires 32-128 + * alphanumeric-or-hyphen characters) for driving requests with distinct + * cookies through the harness. + */ +function inProcessHarnessSessionId(): string +{ + return bin2hex(random_bytes(20)); +} + +/** + * Build a Request against the fixture app's demo routes. + * + * @param array $cookies + */ +function inProcessHarnessRequest( + string $method, + string $uri, + array $cookies = [], +): Request { + return new Request( + server: [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $uri, + ], + cookies: $cookies, + ); +} + +/** + * Absolute path to the monorepo root (four levels above this file: + * tests/ -> roadrunner/ -> packages/ -> repo root). + */ +function monorepoRootPath(): string +{ + return dirname(__DIR__, 3); +} + +/** + * Parse a module.php file's `singletons` declaration into the short + * (unqualified) identifiers it registers, used to mechanically cross-check + * the state-leak findings document against every singleton actually + * declared in the monorepo, rather than trusting a hand-maintained list. + * + * Handles both list form (`[Foo::class]`) and keyed form + * (`[Interface::class => Concrete::class]` or `[Interface::class => Closure]`) + * — the identifier is the string key when present, the string value + * otherwise. + * + * @return list + */ +function moduleSingletonIdentifiers( + string $moduleFile, +): array { + /** @var array{singletons?: array} $config */ + $config = require $moduleFile; + $singletons = $config['singletons'] ?? []; + $identifiers = []; + + foreach ($singletons as $key => $value) { + $identifier = is_string($key) ? $key : $value; + + if (!is_string($identifier)) { + continue; + } + + $identifiers[] = str_contains($identifier, '\\') + ? substr($identifier, strrpos($identifier, '\\') + 1) + : $identifier; + } + + return array_values(array_unique($identifiers)); +} diff --git a/packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php b/packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php new file mode 100644 index 00000000..8c64b728 --- /dev/null +++ b/packages/roadrunner/tests/Http/Psr7RequestBridgeTest.php @@ -0,0 +1,161 @@ +bridge($psr7Request); + + expect($request->method())->toBe('POST'); + }); + + it('maps the request path from the psr7 uri', function (): void { + $psr7Request = new ServerRequest('GET', 'https://example.test/users/42'); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->path())->toBe('/users/42'); + }); + + it('maps query parameters from the psr7 request', function (): void { + $psr7Request = new ServerRequest('GET', 'https://example.test/users?page=2&sort=name'); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->query('page'))->toBe('2') + ->and($request->query('sort'))->toBe('name'); + }); + + it('maps parsed body parameters to the post array', function (): void { + $psr7Request = (new ServerRequest('POST', 'https://example.test/users')) + ->withParsedBody(['name' => 'Ada']); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->post('name'))->toBe('Ada'); + }); + + it('maps psr7 headers so that header lookup works', function (): void { + $psr7Request = new ServerRequest( + 'GET', + 'https://example.test/users', + ['X-Custom-Header' => 'custom-value'], + ); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->header('X-Custom-Header'))->toBe('custom-value'); + }); + + it('joins multi value psr7 headers into a single server entry', function (): void { + $psr7Request = new ServerRequest( + 'GET', + 'https://example.test/users', + ['Accept' => ['text/html', 'application/json']], + ); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->header('Accept'))->toBe('text/html, application/json'); + }); + + it('includes the query string in the request uri server key', function (): void { + $psr7Request = new ServerRequest('GET', 'https://example.test/users?page=2&sort=name'); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->server('REQUEST_URI'))->toBe('/users?page=2&sort=name'); + }); + + it('maps the remote address so that ip lookup works', function (): void { + $psr7Request = new ServerRequest( + 'GET', + 'https://example.test/users', + [], + null, + '1.1', + ['REMOTE_ADDR' => '203.0.113.7'], + ); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->ip())->toBe('203.0.113.7'); + }); + + it('maps content type and content length as bare server keys', function (): void { + $psr7Request = new ServerRequest( + 'POST', + 'https://example.test/users', + [ + 'Content-Type' => 'application/json', + 'Content-Length' => '42', + ], + ); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->server('CONTENT_TYPE'))->toBe('application/json') + ->and($request->server('CONTENT_LENGTH'))->toBe('42'); + }); + + it('sets the https server key only for https requests', function (): void { + $httpsRequest = new ServerRequest('GET', 'https://example.test/users'); + $httpRequest = new ServerRequest('GET', 'http://example.test/users'); + + $bridge = new Psr7RequestBridge(); + + expect($bridge->bridge($httpsRequest)->server('HTTPS'))->toBe('on') + ->and($bridge->bridge($httpRequest)->server('HTTPS'))->toBeNull(); + }); + + it('maps cookies from the psr7 request', function (): void { + $psr7Request = (new ServerRequest('GET', 'https://example.test/users')) + ->withCookieParams(['session' => 'abc123']); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->cookie('session'))->toBe('abc123'); + }); + + it('parses a form encoded body for put patch and delete requests', function (): void { + $psr7Request = new ServerRequest( + 'PUT', + 'https://example.test/users/1', + ['Content-Type' => 'application/x-www-form-urlencoded'], + 'name=Ada&role=admin', + ); + + $request = (new Psr7RequestBridge())->bridge($psr7Request); + + expect($request->post('name'))->toBe('Ada') + ->and($request->post('role'))->toBe('admin'); + }); + + it('throws a loud error when the psr7 request carries uploaded files', function (): void { + $uploadedFile = new UploadedFile( + Stream::create('file contents'), + 13, + UPLOAD_ERR_OK, + 'avatar.png', + 'image/png', + ); + + $psr7Request = (new ServerRequest('POST', 'https://example.test/users')) + ->withUploadedFiles(['avatar' => $uploadedFile]); + + $bridge = new Psr7RequestBridge(); + + expect(fn () => $bridge->bridge($psr7Request)) + ->toThrow(UploadedFilesNotSupportedException::class); + }); +}); diff --git a/packages/roadrunner/tests/Http/Psr7ResponseBridgeTest.php b/packages/roadrunner/tests/Http/Psr7ResponseBridgeTest.php new file mode 100644 index 00000000..d94a6270 --- /dev/null +++ b/packages/roadrunner/tests/Http/Psr7ResponseBridgeTest.php @@ -0,0 +1,102 @@ +bridge($response); + + expect($psr7Response->getStatusCode())->toBe(201); +}); + +it('maps the body to the psr7 response', function (): void { + $response = new Response(body: 'hello world', statusCode: 200); + $bridge = new Psr7ResponseBridge(); + + $psr7Response = $bridge->bridge($response); + + expect((string) $psr7Response->getBody())->toBe('hello world'); +}); + +it('maps regular headers to the psr7 response', function (): void { + $response = new Response( + body: '', + statusCode: 200, + headers: ['Content-Type' => 'application/json', 'X-Request-Id' => 'abc123'], + ); + $bridge = new Psr7ResponseBridge(); + + $psr7Response = $bridge->bridge($response); + + expect($psr7Response->getHeaderLine('Content-Type'))->toBe('application/json') + ->and($psr7Response->getHeaderLine('X-Request-Id'))->toBe('abc123'); +}); + +it('emits a distinct set cookie header for each cookie on the response', function (): void { + $cookie = new Cookie(name: 'session', value: 'abc123'); + $response = (new Response(body: '', statusCode: 200))->withCookie($cookie); + $bridge = new Psr7ResponseBridge(); + + $psr7Response = $bridge->bridge($response); + + expect($psr7Response->getHeader('Set-Cookie'))->toBe([$cookie->toSetCookieString()]); +}); + +it('preserves multiple cookies rather than collapsing them', function (): void { + $sessionCookie = new Cookie(name: 'session', value: 'abc123'); + $preferencesCookie = new Cookie(name: 'preferences', value: 'dark-mode'); + $response = (new Response(body: '', statusCode: 200)) + ->withCookie($sessionCookie) + ->withCookie($preferencesCookie); + $bridge = new Psr7ResponseBridge(); + + $psr7Response = $bridge->bridge($response); + + expect($psr7Response->getHeader('Set-Cookie'))->toBe([ + $sessionCookie->toSetCookieString(), + $preferencesCookie->toSetCookieString(), + ]); +}); + +it('preserves a header value containing a colon', function (): void { + $response = new Response( + body: '', + statusCode: 302, + headers: ['Location' => 'https://example.test/path'], + ); + $bridge = new Psr7ResponseBridge(); + + $psr7Response = $bridge->bridge($response); + + expect($psr7Response->getHeaderLine('Location'))->toBe('https://example.test/path'); +}); + +it('throws when handed a streaming response', function (): void { + $stream = new SseStream(dataProvider: fn (): array => []); + $response = new StreamingResponse($stream); + $bridge = new Psr7ResponseBridge(); + + expect(fn () => $bridge->bridge($response)) + ->toThrow(StreamingResponseException::class); +}); + +it('does not require the sse package to be installed', function (): void { + $response = new Response(body: 'ok', statusCode: 200); + $bridge = new Psr7ResponseBridge(streamingResponseClass: 'Marko\Sse\NotInstalledStreamingResponse'); + + $psr7Response = $bridge->bridge($response); + + expect($psr7Response->getStatusCode())->toBe(200) + ->and((string) $psr7Response->getBody())->toBe('ok'); +}); diff --git a/packages/roadrunner/tests/Process/ProcessRunnerTest.php b/packages/roadrunner/tests/Process/ProcessRunnerTest.php new file mode 100644 index 00000000..29f73cf0 --- /dev/null +++ b/packages/roadrunner/tests/Process/ProcessRunnerTest.php @@ -0,0 +1,16 @@ +run('true'))->toBe(0) + ->and($runner->run('false'))->toBe(1); + }); +}); diff --git a/packages/roadrunner/tests/StateLeakSpikeTest.php b/packages/roadrunner/tests/StateLeakSpikeTest.php new file mode 100644 index 00000000..16452ce6 --- /dev/null +++ b/packages/roadrunner/tests/StateLeakSpikeTest.php @@ -0,0 +1,262 @@ + anonymous -> a different session. + $authenticated = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + $harness->reset(); + + $anonymous = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/read', + [$cookieName => inProcessHarnessSessionId()], + )); + $harness->reset(); + + $different = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + + expect($authenticated->body())->toContain('visits=1') + ->and($anonymous->body())->toContain('visits=0') + ->and($different->body())->toContain('visits=1'); + }); + + it('does not carry the authenticated user from one request into the next', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + + // Interleave: authenticated -> anonymous -> a different session. + $authenticated = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + $harness->reset(); + + $anonymous = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/read', + [$cookieName => inProcessHarnessSessionId()], + )); + $harness->reset(); + + $different = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + + expect($authenticated->body())->toContain('user=1') + ->and($anonymous->body())->toContain('user=guest') + ->and($different->body())->toContain('user=1'); + }); + + it('does not carry request scoped container state between requests', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + + // DemoController is not declared a singleton in the fixture app, so + // each resolution the Router performs per request must be a fresh + // instance — nothing about handling one request's controller may + // bleed into the next request's controller instance. + $first = $harness->container()->get(DemoController::class); + $second = $harness->container()->get(DemoController::class); + + expect($first)->not->toBe($second); + }); + + it('does not accumulate shutdown functions across requests', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + + // Session::configure() guards session_set_save_handler()'s implicit + // register_shutdown_function() call behind a handlerRegistered flag + // so it only ever fires once per process, however many times + // start() runs across however many requests (see Session.php). + $session = $harness->container()->get(SessionInterface::class); + $reflection = new ReflectionProperty($session, 'handlerRegistered'); + + for ($i = 0; $i < 10; $i++) { + (void) $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + } + + expect($reflection->getValue($session))->toBeTrue(); + }); + + it('does not drift the output buffer level across requests', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + $levelBefore = ob_get_level(); + + for ($i = 0; $i < 20; $i++) { + (void) $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/read', + [$cookieName => inProcessHarnessSessionId()], + )); + } + + expect(ob_get_level())->toBe($levelBefore); + }); + + it('leaves no active session when a request throws', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + + $thrown = catchThrowable(function () use ($harness, $cookieName): void { + (void) $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/throw', + [$cookieName => inProcessHarnessSessionId()], + )); + }); + + expect($thrown)->not->toBeNull() + ->and(session_status())->not->toBe(PHP_SESSION_ACTIVE); + }); + + it('does not grow memory unboundedly across several hundred requests', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + $requestCount = 400; + $sampleEvery = 50; + + /** @var list $samples */ + $samples = []; + + for ($i = 0; $i < $requestCount; $i++) { + (void) $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + $harness->reset(); + + if ($i % $sampleEvery === 0) { + $samples[] = memory_get_usage(true); + } + } + + $growth = end($samples) - $samples[0]; + + // A generous bound: hundreds of requests through one booted app + // should not grow resident memory by tens of megabytes. This is a + // curve check, not a zero-growth check — some growth from opcache + // warmup and PHP's own allocator behaviour is expected and fine. + expect($growth)->toBeLessThan(25 * 1024 * 1024); + }); + + it('records every confirmed leak in the findings document', function (): void { + $doc = file_get_contents( + monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md', + ); + + expect($doc) + ->toContain('Session') + ->toContain('SessionGuard') + ->toContain('ReadWriteConnection') + ->toContain('Inertia') + ->toContain('Debugbar') + ->toContain('DatabaseConnectionPlugin') + ->toContain('ViewPlugin'); + }); + + it('records investigated leads that turned out not to leak', function (): void { + $doc = file_get_contents( + monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md', + ); + + expect($doc) + ->toContain('EntityCompanionStorage') + ->toContain('RouteCollection') + ->toContain('PolicyRegistry') + ->toContain('IndexCache') + ->toContain('mt_srand') + ->toContain('date_default_timezone_set'); + }); + + it('records a verdict for every singleton declared across the monorepo', function (): void { + $root = monorepoRootPath(); + $doc = file_get_contents( + $root . '/packages/docs-markdown/docs/packages/roadrunner-state-leaks.md', + ); + + $moduleFiles = [ + 'authentication' => $root . '/packages/authentication/module.php', + 'authorization' => $root . '/packages/authorization/module.php', + 'codeindexer' => $root . '/packages/codeindexer/module.php', + 'database' => $root . '/packages/database/module.php', + 'debugbar' => $root . '/packages/debugbar/module.php', + 'devai' => $root . '/packages/devai/module.php', + 'docs-fts' => $root . '/packages/docs-fts/module.php', + 'docs-markdown' => $root . '/packages/docs-markdown/module.php', + 'docs' => $root . '/packages/docs/module.php', + 'inertia' => $root . '/packages/inertia/module.php', + 'layout' => $root . '/packages/layout/module.php', + 'lsp' => $root . '/packages/lsp/module.php', + 'mcp' => $root . '/packages/mcp/module.php', + 'session-database' => $root . '/packages/session-database/module.php', + 'session-file' => $root . '/packages/session-file/module.php', + 'vite' => $root . '/packages/vite/module.php', + 'codeindexer fixture' => $root + . '/packages/codeindexer/tests/Fixtures/MiniMonorepo/vendor/foo/bar/module.php', + ]; + + $lines = explode("\n", $doc); + $missingVerdicts = []; + + foreach ($moduleFiles as $package => $moduleFile) { + $identifiers = moduleSingletonIdentifiers($moduleFile); + + if ($identifiers === []) { + if (!str_contains($doc, $package)) { + $missingVerdicts[] = $package . ' (no singletons declared)'; + } + + continue; + } + + foreach ($identifiers as $identifier) { + $hasVerdictLine = array_any( + $lines, + fn (string $line): bool => str_contains($line, $identifier) && str_contains($line, 'Leaks:'), + ); + + if (!$hasVerdictLine) { + $missingVerdicts[] = "$package: $identifier"; + } + } + } + + expect($missingVerdicts)->toBeEmpty(); + }); +}); diff --git a/packages/roadrunner/tests/Support/InProcessRequestHarness.php b/packages/roadrunner/tests/Support/InProcessRequestHarness.php new file mode 100644 index 00000000..5250b9da --- /dev/null +++ b/packages/roadrunner/tests/Support/InProcessRequestHarness.php @@ -0,0 +1,94 @@ +application()->router->handle($request); + } + + /** + * @throws ModuleException|CircularDependencyException|BindingConflictException|BindingException|PluginException|PreferenceConflictException|EventException|ContainerExceptionInterface|RouteException|RouteConflictException|CommandException|ReflectionException|RuntimeException|DiscoveryCacheException + */ + public function container(): Container + { + $container = $this->application()->container; + + if (!$container instanceof Container) { + throw new RuntimeException( + 'Expected the booted application to expose a concrete Container instance.', + ); + } + + return $container; + } + + /** + * Clear request-scoped state from every currently resolved + * ResettableInterface instance (e.g. Session, SessionGuard). Opt-in and + * non-destructive — nothing is reset automatically between handle() + * calls, so leak scenarios remain observable unless a caller resets. + * + * @throws ModuleException|CircularDependencyException|BindingConflictException|BindingException|PluginException|PreferenceConflictException|EventException|ContainerExceptionInterface|RouteException|RouteConflictException|CommandException|ReflectionException|RuntimeException|DiscoveryCacheException + */ + public function reset(): void + { + foreach ($this->container()->resolvedInstances(ResettableInterface::class) as $resettable) { + $resettable->reset(); + } + } + + /** + * @throws ModuleException|CircularDependencyException|BindingConflictException|BindingException|PluginException|PreferenceConflictException|EventException|ContainerExceptionInterface|RouteException|RouteConflictException|CommandException|ReflectionException|RuntimeException|DiscoveryCacheException + */ + private function application(): Application + { + return $this->application ??= Application::boot($this->basePath); + } +} diff --git a/packages/roadrunner/tests/Support/InProcessRequestHarnessTest.php b/packages/roadrunner/tests/Support/InProcessRequestHarnessTest.php new file mode 100644 index 00000000..e96332a4 --- /dev/null +++ b/packages/roadrunner/tests/Support/InProcessRequestHarnessTest.php @@ -0,0 +1,98 @@ +handle(inProcessHarnessRequest('GET', '/session/read')); + $containerAfterFirstRequest = $harness->container(); + + $secondResponse = $harness->handle(inProcessHarnessRequest('GET', '/session/read')); + $containerAfterSecondRequest = $harness->container(); + + expect($containerAfterSecondRequest)->toBe($containerAfterFirstRequest) + ->and($firstResponse->statusCode())->toBe(200) + ->and($secondResponse->statusCode())->toBe(200); + }); + + it('returns a response for each request driven through the harness', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + + $write = $harness->handle(inProcessHarnessRequest('GET', '/session/write')); + $read = $harness->handle(inProcessHarnessRequest('GET', '/session/read')); + + expect($write->statusCode())->toBe(200) + ->and($write->body())->toContain('visits=1') + ->and($read->statusCode())->toBe(200); + }); + + it('drives requests carrying different cookies', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + + $first = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + $second = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + + expect($first->body())->not->toBe($second->body()); + }); + + it('exposes the booted application container to the caller', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + + expect($harness->container())->toBeInstanceOf(Container::class) + ->and($harness->container()->has(SessionInterface::class))->toBeTrue(); + }); + + it('exposes a reset hook that runs between requests', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + $cookieName = inProcessHarnessSessionCookieName(); + + $first = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/write', + [$cookieName => inProcessHarnessSessionId()], + )); + + $harness->reset(); + + $second = $harness->handle(inProcessHarnessRequest( + 'GET', + '/session/read', + [$cookieName => inProcessHarnessSessionId()], + )); + + expect($first->body())->toContain('user=1') + ->and($second->body())->toContain('user=guest'); + }); + + it('requires no roadrunner binary', function (): void { + $harness = new InProcessRequestHarness(inProcessHarnessFixturePath()); + + $response = $harness->handle(inProcessHarnessRequest('GET', '/session/read')); + + expect(getenv('RR_MODE'))->toBeFalse() + ->and($response->statusCode())->toBe(200); + }); +}); diff --git a/packages/roadrunner/tests/Worker/BasePathResolverTest.php b/packages/roadrunner/tests/Worker/BasePathResolverTest.php new file mode 100644 index 00000000..65456b77 --- /dev/null +++ b/packages/roadrunner/tests/Worker/BasePathResolverTest.php @@ -0,0 +1,51 @@ +resolve(); + + expect($resolved)->toBe($projectRoot); + } finally { + unlink($projectRoot . '/vendor/marko-roadrunner-symlink'); + unlink($projectRoot . '/vendor/composer/ClassLoader.php'); + rmdir($projectRoot . '/vendor/composer'); + rmdir($projectRoot . '/vendor'); + rmdir($projectRoot . '/app'); + rmdir($projectRoot . '/modules'); + rmdir($projectRoot); + rmdir($packageSource); + $previousEnv === false ? putenv('MARKO_BASE_PATH') : putenv("MARKO_BASE_PATH=$previousEnv"); + } + }); +}); diff --git a/packages/roadrunner/tests/Worker/FakeComposerAutoloaderLocator.php b/packages/roadrunner/tests/Worker/FakeComposerAutoloaderLocator.php new file mode 100644 index 00000000..cb4b1dde --- /dev/null +++ b/packages/roadrunner/tests/Worker/FakeComposerAutoloaderLocator.php @@ -0,0 +1,19 @@ +file; + } +} diff --git a/packages/roadrunner/tests/Worker/FakePsr7Worker.php b/packages/roadrunner/tests/Worker/FakePsr7Worker.php new file mode 100644 index 00000000..aae74550 --- /dev/null +++ b/packages/roadrunner/tests/Worker/FakePsr7Worker.php @@ -0,0 +1,44 @@ + */ + public private(set) array $responses = []; + + /** + * @param list $requests + */ + public function __construct( + private array $requests, + ) {} + + public function waitRequest(): ?ServerRequestInterface + { + return array_shift($this->requests); + } + + public function respond(ResponseInterface $response): void + { + $this->responses[] = $response; + } + + public function getWorker(): WorkerInterface + { + throw new RuntimeException('FakePsr7Worker does not support getWorker().'); + } +} diff --git a/packages/roadrunner/tests/Worker/FakeRouteMatcher.php b/packages/roadrunner/tests/Worker/FakeRouteMatcher.php new file mode 100644 index 00000000..4b499c7a --- /dev/null +++ b/packages/roadrunner/tests/Worker/FakeRouteMatcher.php @@ -0,0 +1,33 @@ +matchCount++; + + return $this->onMatch !== null ? ($this->onMatch)($method, $path) : null; + } +} diff --git a/packages/roadrunner/tests/Worker/NullContainer.php b/packages/roadrunner/tests/Worker/NullContainer.php new file mode 100644 index 00000000..8e8b9afb --- /dev/null +++ b/packages/roadrunner/tests/Worker/NullContainer.php @@ -0,0 +1,53 @@ + + */ + public function resolvedInstances(?string $interface = null): array + { + return []; + } +} diff --git a/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php b/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php new file mode 100644 index 00000000..8ab48c59 --- /dev/null +++ b/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php @@ -0,0 +1,204 @@ +run(); + + expect($matcher->matchCount)->toBe(3) + ->and($psr7Worker->responses)->toHaveCount(3); + }); + + it('returns a response for each request it receives', function (): void { + $matcher = new FakeRouteMatcher(); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + new ServerRequest('GET', 'https://example.test/two'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + + $handler->run(); + + expect($psr7Worker->responses)->toHaveCount(2) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(404) + ->and($psr7Worker->responses[1]->getStatusCode())->toBe(404); + }); + + it('converts an unhandled exception into a five hundred response', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (): never { + throw new RuntimeException('route matching exploded'); + }); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/boom'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + + $handler->run(); + + expect($psr7Worker->responses)->toHaveCount(1) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(500); + }); + + it('omits exception details from the five hundred body outside development', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (): never { + throw new RuntimeException('super secret database credentials leaked here'); + }); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/boom'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + development: false, + ); + + $handler->run(); + + $body = (string) $psr7Worker->responses[0]->getBody(); + + expect($body)->not->toContain('super secret database credentials leaked here') + ->and($body)->not->toContain(RuntimeException::class); + }); + + it('continues serving after a request throws', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (string $method, string $path): ?MatchedRoute { + if ($path === '/boom') { + throw new RuntimeException('request two exploded'); + } + + return null; + }); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/first'), + new ServerRequest('GET', 'https://example.test/boom'), + new ServerRequest('GET', 'https://example.test/third'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + + $handler->run(); + + expect($psr7Worker->responses)->toHaveCount(3) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(404) + ->and($psr7Worker->responses[1]->getStatusCode())->toBe(500) + ->and($psr7Worker->responses[2]->getStatusCode())->toBe(404); + }); + + it('stops looping when the worker signals no further requests', function (): void { + $matcher = new FakeRouteMatcher(); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + new ServerRequest('GET', 'https://example.test/two'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + + $handler->run(); + + expect($matcher->matchCount)->toBe(2) + ->and($psr7Worker->responses)->toHaveCount(2); + }); + + it('captures stray application output instead of writing it to standard out', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (): ?MatchedRoute { + echo 'stray application output that must never reach the relay'; + + return null; + }); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/noisy'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + + $handler->run(); + + expect($psr7Worker->responses)->toHaveCount(1) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(404); + })->expectOutputString(''); + + it('restores the output buffer level after a request throws', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (): never { + throw new RuntimeException('boom while buffering'); + }); + $router = new Router($matcher, new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/boom'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + ); + $levelBefore = ob_get_level(); + + $handler->run(); + + expect(ob_get_level())->toBe($levelBefore); + }); +}); diff --git a/packages/roadrunner/tests/Worker/WorkerSafeExceptionHandlerTest.php b/packages/roadrunner/tests/Worker/WorkerSafeExceptionHandlerTest.php new file mode 100644 index 00000000..a9aad9c2 --- /dev/null +++ b/packages/roadrunner/tests/Worker/WorkerSafeExceptionHandlerTest.php @@ -0,0 +1,34 @@ + null); + + $handler = new WorkerSafeExceptionHandler(new WorkerLogger()); + + try { + $handler->install(); + + $currentlyInstalled = set_exception_handler(fn (Throwable $throwable) => null); + $reflection = new ReflectionFunction($currentlyInstalled); + + expect($reflection->getClosureThis())->toBe($handler) + ->and($reflection->getName())->toBe('handle'); + } finally { + restore_exception_handler(); + restore_exception_handler(); + restore_exception_handler(); + } + }); +}); diff --git a/packages/roadrunner/tests/WorkerBootFailureTest.php b/packages/roadrunner/tests/WorkerBootFailureTest.php new file mode 100644 index 00000000..c53ae907 --- /dev/null +++ b/packages/roadrunner/tests/WorkerBootFailureTest.php @@ -0,0 +1,73 @@ + 'fixture/failing-boot', + 'extra' => ['marko' => ['module' => true]], + ], JSON_THROW_ON_ERROR), + ); + file_put_contents( + $fixtureRoot . '/app/failing/module.php', + " function () { throw new \\RuntimeException(" + . var_export($failureMessage, true) . '); }];' . "\n", + ); + + $workerScript = dirname(__DIR__) . '/worker.php'; + + $process = proc_open( + [PHP_BINARY, $workerScript], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + null, + array_merge($_ENV, ['MARKO_BASE_PATH' => $fixtureRoot]), + ); + + try { + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + expect($exitCode)->not->toBe(0) + ->and($stdout)->toBe('') + ->and($stderr)->toContain($failureMessage); + } finally { + unlink($fixtureRoot . '/app/failing/module.php'); + unlink($fixtureRoot . '/app/failing/composer.json'); + rmdir($fixtureRoot . '/app/failing'); + rmdir($fixtureRoot . '/app'); + unlink($fixtureRoot . '/vendor/autoload.php'); + rmdir($fixtureRoot . '/vendor'); + rmdir($fixtureRoot . '/modules'); + rmdir($fixtureRoot); + } + }); +}); diff --git a/packages/roadrunner/worker.php b/packages/roadrunner/worker.php new file mode 100644 index 00000000..779f3fa8 --- /dev/null +++ b/packages/roadrunner/worker.php @@ -0,0 +1,98 @@ +resolve(); + +try { + $app = Application::boot($basePath); + + $warnings = $app->container->get(UnsafePackageChecker::class)->check(); + foreach ($warnings as $warning) { + fwrite(STDERR, "[worker] $warning" . PHP_EOL); + } +} catch (Throwable $throwable) { + // Boot failure must not loop: write the real reason to STDERR (the only + // stream a worker may write to — STDOUT carries the goridge protocol) + // and exit non-zero so RoadRunner restarts the worker instead of this + // process answering every request with a 500 forever. + fwrite(STDERR, sprintf( + 'Marko application failed to boot: %s: %s in %s:%d%s%s' . PHP_EOL, + $throwable::class, + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine(), + PHP_EOL, + $throwable->getTraceAsString(), + )); + + exit(1); +} + +$development = strtolower((string) (getenv('APP_ENV') ?: '')) === 'development'; + +$logger = $app->container->has(PsrLoggerInterface::class) + ? $app->container->get(PsrLoggerInterface::class) + : null; +$workerLogger = new WorkerLogger($logger); + +// Installed after boot: module boot callbacks (e.g. marko/errors-simple's) +// are what install a handler that would otherwise echo to STDOUT, so this +// must run last to be the one PHP actually keeps. +(new WorkerSafeExceptionHandler($workerLogger))->install(); + +$psr17Factory = new Psr17Factory(); +$psr7Worker = new PSR7Worker( + RoadRunnerWorker::create(), + $psr17Factory, + $psr17Factory, + $psr17Factory, +); + +$requestHandler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $app->router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: $workerLogger, + development: $development, +); + +$requestHandler->run(); diff --git a/phpstan.neon b/phpstan.neon index 48c9e560..88e43c1d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ parameters: level: 6 paths: - packages/core/src + - packages/roadrunner/src excludePaths: - packages/core/tests ignoreErrors: diff --git a/tests/Fixtures/Psr7Containment/MentionsPsr7InDocblockOnly.php b/tests/Fixtures/Psr7Containment/MentionsPsr7InDocblockOnly.php new file mode 100644 index 00000000..b571aa86 --- /dev/null +++ b/tests/Fixtures/Psr7Containment/MentionsPsr7InDocblockOnly.php @@ -0,0 +1,18 @@ +discover($packagesRoot, excludingPackage: 'roadrunner'); + $violations = (new Psr7ContainmentDetector())->scan($files); + + // filesystem-s3 type-hints the RequestInterface returned by aws-sdk-php's + // presigned-URL builder — a pre-existing, unrelated PSR-7 touchpoint from the + // AWS SDK's own dependency graph, not a leak of the roadrunner/routing boundary + // this test guards. Every other confined reference must still be zero. + $violations = array_filter( + $violations, + fn (array $violation): bool => $violation['file'] !== $packagesRoot + . '/filesystem-s3/src/Filesystem/S3Filesystem.php', + ); + + expect($files)->not->toBeEmpty() + ->and($violations)->toBeEmpty(); +}); + +it('excludes the roadrunner package from discovery', function () use ($packagesRoot): void { + $files = (new Psr7SymbolDiscovery())->discover($packagesRoot, excludingPackage: 'roadrunner'); + + $roadrunnerFiles = array_filter( + $files, + fn (string $file): bool => str_starts_with($file, $packagesRoot . '/roadrunner/'), + ); + + expect($roadrunnerFiles)->toBeEmpty(); +}); + +it('flags a Psr\Http\Message symbol used outside the roadrunner package', function () use ($fixturesRoot): void { + $violations = (new Psr7ContainmentDetector())->scan([$fixturesRoot . '/ViolatingPsr7Usage.php']); + + expect($violations)->not->toBeEmpty() + ->and($violations[0]['symbol'])->toContain('Psr\\Http\\Message'); +}); + +it('does not flag a docblock or string mention of a confined namespace', function () use ($fixturesRoot): void { + $violations = (new Psr7ContainmentDetector())->scan([$fixturesRoot . '/MentionsPsr7InDocblockOnly.php']); + + expect($violations)->toBeEmpty(); +}); diff --git a/tests/RoadrunnerScaffoldingTest.php b/tests/RoadrunnerScaffoldingTest.php new file mode 100644 index 00000000..d2596477 --- /dev/null +++ b/tests/RoadrunnerScaffoldingTest.php @@ -0,0 +1,40 @@ +toContain('packages/roadrunner'); +}); + +it('is included in the phpstan analysis paths', function () use ($rootPath): void { + $phpstan = file_get_contents($rootPath . '/phpstan.neon'); + + expect($phpstan)->toContain('packages/roadrunner/src'); +}); + +it('is required by the root composer json', function () use ($rootPath): void { + $composer = json_decode(file_get_contents($rootPath . '/composer.json'), true); + + expect($composer['require'])->toHaveKey('marko/roadrunner') + ->and($composer['require']['marko/roadrunner'])->toBe('self.version'); +}); + +it('maps the package test namespace in root autoload dev', function () use ($rootPath): void { + $composer = json_decode(file_get_contents($rootPath . '/composer.json'), true); + + expect($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\Roadrunner\\Tests\\') + ->and($composer['autoload-dev']['psr-4']['Marko\\Roadrunner\\Tests\\'])->toBe('packages/roadrunner/tests/'); +}); + +it('declares the roadrunner and psr7 dependencies in the root require dev', function () use ($rootPath): void { + $composer = json_decode(file_get_contents($rootPath . '/composer.json'), true); + + expect($composer['require-dev'])->toHaveKey('spiral/roadrunner-http') + ->and($composer['require-dev'])->toHaveKey('nyholm/psr7'); +}); diff --git a/tests/Support/Psr7Containment/Psr7ContainmentDetector.php b/tests/Support/Psr7Containment/Psr7ContainmentDetector.php new file mode 100644 index 00000000..9767fb17 --- /dev/null +++ b/tests/Support/Psr7Containment/Psr7ContainmentDetector.php @@ -0,0 +1,88 @@ + + */ + private const array CONFINED_PREFIXES = [ + 'Psr\\Http\\Message', + 'Nyholm\\Psr7', + 'Spiral\\RoadRunner', + ]; + + /** + * @param list $files + * + * @return list + */ + public function scan(array $files): array + { + $violations = []; + + foreach ($files as $file) { + foreach ($this->confinedSymbolsIn($file) as $symbol) { + $violations[] = ['file' => $file, 'symbol' => $symbol]; + } + } + + return $violations; + } + + /** + * @return list + */ + private function confinedSymbolsIn(string $file): array + { + $content = file_get_contents($file); + + if ($content === false) { + return []; + } + + $symbols = []; + + foreach (PhpToken::tokenize($content) as $token) { + $isNameToken = in_array( + $token->id, + [T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE], + true, + ); + + if (!$isNameToken) { + continue; + } + + $name = ltrim($token->text, '\\'); + + foreach (self::CONFINED_PREFIXES as $prefix) { + if (str_starts_with($name, $prefix)) { + $symbols[] = $token->text; + } + } + } + + return $symbols; + } +} diff --git a/tests/Support/Psr7Containment/Psr7SymbolDiscovery.php b/tests/Support/Psr7Containment/Psr7SymbolDiscovery.php new file mode 100644 index 00000000..365ce2e1 --- /dev/null +++ b/tests/Support/Psr7Containment/Psr7SymbolDiscovery.php @@ -0,0 +1,101 @@ + + * + * @throws UnexpectedValueException + */ + public function discover( + string $packagesRoot, + string $excludingPackage, + ): array + { + $files = []; + + foreach ($this->packageDirectories($packagesRoot, $excludingPackage) as $directory) { + foreach ($this->phpFilesUnder($directory) as $file) { + $files[] = $file; + } + } + + sort($files); + + return $files; + } + + /** + * @return list + */ + private function packageDirectories( + string $packagesRoot, + string $excludingPackage, + ): array + { + $entries = scandir($packagesRoot); + + if ($entries === false) { + return []; + } + + $directories = []; + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || $entry === $excludingPackage) { + continue; + } + + foreach (['src', 'tests'] as $subdirectory) { + $path = $packagesRoot . '/' . $entry . '/' . $subdirectory; + + if (is_dir($path)) { + $directories[] = $path; + } + } + } + + return $directories; + } + + /** + * @return list + * + * @throws UnexpectedValueException + */ + private function phpFilesUnder(string $directory): array + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + + $files = []; + + foreach ($iterator as $fileInfo) { + /** @var SplFileInfo $fileInfo */ + if ($fileInfo->isFile() && $fileInfo->getExtension() === 'php') { + $files[] = $fileInfo->getPathname(); + } + } + + return $files; + } +} From 4102dfafa2c38fe7423d8e71197037ea19f85de3 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Fri, 28 Aug 2026 19:34:19 -0400 Subject: [PATCH 2/4] feat(roadrunner): worker reset lifecycle, end-to-end suite and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL --- .claude/architecture.md | 8 +- .../plans/roadrunner/006-reset-lifecycle.md | 135 ++++++-- .../plans/roadrunner/009-end-to-end-test.md | 126 +++++++- .../plans/roadrunner/010-docs-and-readme.md | 26 +- .claude/plans/roadrunner/_plan.md | 8 +- .github/workflows/nightly.yml | 7 + composer.json | 1 + .../docs-markdown/docs/packages/inertia.md | 18 +- .../docs-markdown/docs/packages/roadrunner.md | 153 +++++++++ packages/roadrunner/README.md | 23 ++ .../src/Worker/WorkerRequestHandler.php | 35 ++ packages/roadrunner/tests/DocsTest.php | 97 ++++++ .../src/Http/Controllers/DemoController.php | 28 ++ .../tests/Fixtures/app/config/encryption.php | 10 + .../tests/Fixtures/app/modules/.gitkeep | 0 .../tests/Fixtures/app/vendor/autoload.php | 15 + .../app/vendor/marko/encryption/composer.json | 14 + .../app/vendor/marko/encryption/module.php | 14 + .../Fixtures/app/vendor/marko/roadrunner | 1 + .../app/vendor/marko/security/composer.json | 16 + .../app/vendor/marko/security/module.php | 22 ++ packages/roadrunner/tests/Helpers.php | 88 +++++ .../tests/Integration/EndToEndTest.php | 152 +++++++++ .../tests/Support/RoadRunnerHttpClient.php | 154 +++++++++ .../tests/Support/RoadRunnerHttpResponse.php | 33 ++ .../tests/Support/RoadRunnerServerProcess.php | 217 +++++++++++++ .../tests/Support/SharedRoadRunnerServer.php | 46 +++ .../tests/Worker/FaultyResettable.php | 24 ++ .../tests/Worker/RecordingResettable.php | 31 ++ .../tests/Worker/StubResettableContainer.php | 74 +++++ .../Worker/WorkerRequestHandlerResetTest.php | 302 ++++++++++++++++++ .../tests/Worker/WorkerRequestHandlerTest.php | 8 + packages/roadrunner/worker.php | 1 + tests/CiWorkflowTest.php | 32 +- 34 files changed, 1859 insertions(+), 60 deletions(-) create mode 100644 packages/docs-markdown/docs/packages/roadrunner.md create mode 100644 packages/roadrunner/README.md create mode 100644 packages/roadrunner/tests/DocsTest.php create mode 100644 packages/roadrunner/tests/Fixtures/app/config/encryption.php create mode 100644 packages/roadrunner/tests/Fixtures/app/modules/.gitkeep create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/autoload.php create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/module.php create mode 120000 packages/roadrunner/tests/Fixtures/app/vendor/marko/roadrunner create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/security/composer.json create mode 100644 packages/roadrunner/tests/Fixtures/app/vendor/marko/security/module.php create mode 100644 packages/roadrunner/tests/Integration/EndToEndTest.php create mode 100644 packages/roadrunner/tests/Support/RoadRunnerHttpClient.php create mode 100644 packages/roadrunner/tests/Support/RoadRunnerHttpResponse.php create mode 100644 packages/roadrunner/tests/Support/RoadRunnerServerProcess.php create mode 100644 packages/roadrunner/tests/Support/SharedRoadRunnerServer.php create mode 100644 packages/roadrunner/tests/Worker/FaultyResettable.php create mode 100644 packages/roadrunner/tests/Worker/RecordingResettable.php create mode 100644 packages/roadrunner/tests/Worker/StubResettableContainer.php create mode 100644 packages/roadrunner/tests/Worker/WorkerRequestHandlerResetTest.php diff --git a/.claude/architecture.md b/.claude/architecture.md index 914dc35a..e463dcfd 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -248,6 +248,12 @@ When your code depends on `marko/log` (interface) instead of `marko/log-file` (d | `marko/docs-fts` | Driver | Lexical search driver — SQLite FTS5/BM25; no model required | | `marko/docs-markdown` | Content | Canonical Marko docs content as a Composer module; the marko.build site symlinks to it and the search driver indexes it | +### Application Server + +| Package | Type | Description | +|---------|------|-------------| +| `marko/roadrunner` | Driver | RoadRunner application server driver — serves the application from one long-running worker process instead of a new process per request; the only non-core package analysed by PHPStan (level 6) | + --- ## Naming Conventions @@ -695,7 +701,7 @@ interface ResettableInterface `reset()` must be non-destructive — it clears the instance's in-memory per-request tracking without destroying anything persisted (e.g. resetting a session service forgets which session it was serving, it does not delete the stored session). A long-running process discovers what to reset via `Container::resolvedInstances(ResettableInterface::class)`, which returns only instances the container has already built — never triggering resolution — instead of requiring a hardcoded list. `resolvedInstances()` lives on the concrete `Container` class, not on `ContainerInterface`. -Current implementors: `Session`, `SessionGuard` (`marko/authentication`), and `ReadWriteConnection` (`marko/database-readwrite`). +Current implementors: `Session`, `SessionGuard` (`marko/authentication`), `ReadWriteConnection` (`marko/database-readwrite`), and `Inertia` (`marko/inertia`). ### Preferences diff --git a/.claude/plans/roadrunner/006-reset-lifecycle.md b/.claude/plans/roadrunner/006-reset-lifecycle.md index 7a49a3a4..7d9d3682 100644 --- a/.claude/plans/roadrunner/006-reset-lifecycle.md +++ b/.claude/plans/roadrunner/006-reset-lifecycle.md @@ -1,6 +1,6 @@ # Task 006: Per-Request Reset Lifecycle -**Status**: pending +**Status**: completed **Depends on**: 004, 005 **Retry count**: 0 @@ -18,37 +18,130 @@ Wire a per-request reset for everything task 005's spike confirmed leaks. This i - Resetting must be loud on failure: if a service that should be resettable cannot be reset, fail the request rather than silently serving stale state. Silent degradation here is a cross-user data leak. - Keep the reset ordering deterministic and documented — some resets may depend on others. -### The container cannot tell you what has been resolved +### SUPERSEDED — the container CAN now tell you what has been resolved -`Marko\Core\Container\Container` exposes only `get()`, `has()` and `instance()`. `has()` returns `isset($this->bindings[$id]) || class_exists($id)` (line 61) — it is true for **any class that exists**, so it is useless as an "already instantiated" probe, and `$instances` is private with no accessor. +The section previously here said the container exposed no way to enumerate +resolved instances, and mandated a hardcoded reset list. **That is no longer +true.** #150 gained three amendments after this task was written: -Consequences the implementation must respect: -- The reset list is an **explicit, enumerated list** derived from the spike findings. There is no way to iterate resolved singletons, and adding one is a core change this plan forbids. -- Calling `$container->get(X)` to reset X **instantiates X** if it was not already resolved. For `SessionInterface` that is harmless. For a database connection it means opening a connection on every request, including requests that never touch the database. Reset only what is cheap to resolve, or what the spike proved is already instantiated at boot. +- **Task 011** added `Container::resolvedInstances(?string $interface = null): array`, + returning only instances the container has **already resolved**, optionally + filtered to those implementing a given interface. It never forces + instantiation. +- **Task 012** lifted that method onto `ContainerInterface`, so it is reachable + through the type `Application::$container` is declared as. (This required + updating container stubs in 15 test files across 9 packages — the interface + really is the boundary now.) +- **Tasks 013 and 014** fixed the two leaks the spike found, at their source: + `Inertia` now implements `ResettableInterface` clearing `$shared`, and + `ReadWriteConnection::reset()` now rolls back an `inTransaction()`-guarded + open transaction in addition to clearing sticky-write state. -### `database-readwrite` specifics — verified +**Use generic, container-driven discovery.** Filter +`$container->resolvedInstances(ResettableInterface::class)` and call `reset()` +on each. This is the decided approach, and it is why the accessor exists. -- `resetStickyState()` lives on the concrete `Marko\Database\ReadWrite\Connection\ReadWriteConnection` (line 133), **not** on `ConnectionInterface`. Detect with a `class_exists()`-guarded `instanceof` on the resolved `ConnectionInterface`, not with a package-installed check. -- `database-readwrite/module.php:44-45` registers the connection via `Container::instance()` inside a `boot` callback that returns early unless `config('database.driver') === 'readwrite'`. So it is already instantiated at boot when active, and absent entirely when not — a `$container->get(ConnectionInterface::class)` is safe when `marko/database` is installed, and must be skipped when it is not. -- Plugin interception generates subclasses at runtime; `instanceof` still holds, a `get_class() === ...` comparison would not. +Consequences that still hold and must be respected: + +- **Never call `$container->get(X)` to reset X.** That would instantiate X if the + request never used it — opening a database connection on a request that never + touched the database. `resolvedInstances()` returns only what is already + built, which is exactly the point. +- **A service the request never resolved needs no reset**, by definition. Its + absence from the result is correct, not a missing reset. +- `marko/inertia` and `marko/database-readwrite` need **no special-casing** in + this package. They implement the contract; generic discovery picks them up + when installed and resolved, and they are simply absent otherwise. Do not + write `class_exists()` guards or `instanceof` checks for them. +- Plugin interception generates subclasses at runtime. `instanceof + ResettableInterface` still holds through the generated subclass; a + `get_class() === ...` comparison would not. `resolvedInstances()` filtering is + `instanceof`-based, so this is handled. +- **Reset before each request, not after**, so a request that throws — or a + worker killed mid-request — cannot leave the next one with stale state. +- **Loud on failure**: if a `reset()` throws, fail the request rather than + silently serving stale state. Silent degradation here is a cross-user data + leak. +- Ordering must be deterministic and documented. + +### Not reset targets, by design + +`Debugbar`, `DatabaseConnectionPlugin` and `ViewPlugin` leak but are **not** +made safe by resetting — `Debugbar::boot()`'s unclosed `ob_start()` is +architectural. They are covered by task 007's `UnsafePackageChecker`, which +warns that `marko/debugbar` does not belong in a worker-served environment. Do +not add reset wiring for them. ## Requirements (Test Descriptions) -- [ ] `it resets every service identified by the spike between requests` -- [ ] `it isolates session state between two sequential requests` -- [ ] `it isolates the authenticated user between two sequential requests` -- [ ] `it resets read write sticky state between requests` -- [ ] `it skips the read write reset when the database package is not installed` -- [ ] `it does not instantiate a service that the request never used` -- [ ] `it resets before the request rather than after` -- [ ] `it still resets after a request throws` -- [ ] `it fails the request loudly when a reset cannot be performed` -- [ ] `it performs resets in a deterministic order` +- [x] `it resets every service identified by the spike between requests` +- [x] `it isolates session state between two sequential requests` +- [x] `it isolates the authenticated user between two sequential requests` +- [x] `it resets a resolved resettable service between requests` +- [x] `it skips a resettable service that the container never resolved` +- [x] `it does not instantiate a service that the request never used` +- [x] `it resets before the request rather than after` +- [x] `it still resets after a request throws` +- [x] `it fails the request loudly when a reset cannot be performed` +- [x] `it performs resets in a deterministic order` ## Acceptance Criteria - All requirements have passing tests - Every leak in the spike findings has a corresponding reset and test, or a recorded reason why it needs none -- The reset target list is explicit and documented; no attempt is made to enumerate container instances +- Discovery is generic via `resolvedInstances(ResettableInterface::class)`; no package is special-cased and no service is instantiated to reset it - No file under `packages/core/` is modified - Code follows code standards ## Implementation Notes + +`WorkerRequestHandler` (`packages/roadrunner/src/Worker/WorkerRequestHandler.php`) +now takes a required `ContainerInterface $container` and, inside `handleOne()`, +calls a private `resetResolvedServices()` **before** bridging/routing the +request, inside the same `try` block that already converts any `Throwable` +into a logged 500 — so a `reset()` failure is loud (logged + 500) exactly +like any other request failure, and the worker keeps serving. Discovery is +fully generic: `$this->container->resolvedInstances(ResettableInterface::class)`, +then `ksort()` on the returned `array` before iterating, so +reset order is fixed (ascending by container binding identifier) and does +not depend on which services happened to resolve first for a given request. +No `get()`/`call()` is ever invoked to build something just to reset it. + +`worker.php` now passes `container: $app->container` into the production +`WorkerRequestHandler`. All pre-existing `WorkerRequestHandlerTest.php` +cases were updated to pass `container: new NullContainer()` (already +returns `[]` from `resolvedInstances()`, so their behavior is unchanged). + +One requirement — `it resets every service identified by the spike between +requests` — legitimately required the full mechanism (container injection, +pre-request reset placement, generic `ResettableInterface` filtering) to go +RED→GREEN. The remaining nine requirements (isolation, resolved/unresolved +discovery, no forced instantiation, before-not-after ordering, reset-after- +throw, loud reset failure, deterministic order) all passed immediately once +that generic implementation existed — each still got its own test written +first per the TDD checklist, and each is noted here as passing without +further implementation change, per the "note over-implementation and move +on" rule. This matches the task's own instruction to implement one generic +mechanism rather than special-casing each spike finding. + +New test support added under `packages/roadrunner/tests/Worker/`: +`RecordingResettable` (reset-call spy with optional `onReset` callback for +ordering assertions), `FaultyResettable` (a `ResettableInterface` whose +`reset()` always throws), `StubResettableContainer` (a `ContainerInterface` +stub with a configurable `resolvedInstances()` set whose `get()`/`call()` +throw and count calls, proving the reset loop never reaches for them). +`tests/Helpers.php` gained `inProcessHarnessPsr7Request()`, the PSR-7 +equivalent of the existing `inProcessHarnessRequest()`, for driving +`WorkerRequestHandler` (which only accepts PSR-7 requests) against the same +fixture application `InProcessRequestHarness` already uses. + +Isolation tests for the two spike-confirmed, already-fixed services +(`Session`, `SessionGuard`) drive three interleaved requests — authenticated +→ anonymous → a different session — through a real `WorkerRequestHandler` +wired to a `WorkerRequestHandler`-external, once-booted `Application` +against the existing fixture app, per the task's explicit guidance that an +A→B sequence would not catch a stale identity surviving into an anonymous +request sandwiched between two authenticated ones. + +Full roadrunner suite: 83 passed (170 assertions), up from the 73-test +baseline. `composer phpstan` (which scopes to `packages/core/src` and +`packages/roadrunner/src`): no errors. `phpcs`/`php-cs-fixer` run clean on +every touched file. No file under `packages/core/` was modified. diff --git a/.claude/plans/roadrunner/009-end-to-end-test.md b/.claude/plans/roadrunner/009-end-to-end-test.md index b597e510..6874be84 100644 --- a/.claude/plans/roadrunner/009-end-to-end-test.md +++ b/.claude/plans/roadrunner/009-end-to-end-test.md @@ -1,6 +1,6 @@ # Task 009: End-to-End Integration Test -**Status**: pending +**Status**: completed **Depends on**: 004, 006, 007, 008 **Retry count**: 0 @@ -21,22 +21,116 @@ Prove the whole thing actually works: a real Marko application served by a real Add a step to `nightly.yml` that installs the RoadRunner binary before `composer test:all` (`vendor/bin/rr get-binary` from `spiral/roadrunner-cli`, or download the release archive directly), and add `spiral/roadrunner-cli` to the root `require-dev` if the former. Then assert in `tests/CiWorkflowTest.php` style that the nightly workflow installs it, so a future edit cannot silently drop the step and turn the whole suite back into a skip. ## Requirements (Test Descriptions) -- [ ] `it serves a successful http response through a real roadrunner process` -- [ ] `it preserves a session across two requests from the same client` -- [ ] `it does not leak session state between two different clients` -- [ ] `it does not leak the authenticated user into an anonymous request` -- [ ] `it does not leak the authenticated user between two different clients` -- [ ] `it sets a session cookie on the first request and not on the second` -- [ ] `it passes a csrf protected form submission` -- [ ] `it returns a five hundred and keeps serving after a request throws` -- [ ] `it skips with a clear message when the roadrunner binary is unavailable` -- [ ] `it installs the roadrunner binary in the nightly workflow` +- [x] `it serves a successful http response through a real roadrunner process` +- [x] `it preserves a session across two requests from the same client` +- [x] `it does not leak session state between two different clients` +- [x] `it does not leak the authenticated user into an anonymous request` +- [x] `it does not leak the authenticated user between two different clients` +- [x] `it sets a session cookie on the first request and not on the second` +- [x] `it passes a csrf protected form submission` +- [x] `it returns a five hundred and keeps serving after a request throws` +- [x] `it skips with a clear message when the roadrunner binary is unavailable` +- [x] `it installs the roadrunner binary in the nightly workflow` ## Acceptance Criteria -- All requirements have passing tests -- End-to-end tests are in the `integration-destructive` group -- `composer test` stays green without a RoadRunner binary present -- `nightly.yml` installs the RoadRunner binary so `composer test:all` actually exercises this suite -- Code follows code standards +- [x] All requirements have passing tests +- [x] End-to-end tests are in the `integration-destructive` group +- [x] `composer test` stays green without a RoadRunner binary present +- [x] `nightly.yml` installs the RoadRunner binary so `composer test:all` actually exercises this suite +- [x] Code follows code standards ## Implementation Notes + +All nine `it()` requirements live in `packages/roadrunner/tests/Integration/EndToEndTest.php`. Every +grouped test drives a real `rr serve` subprocess (real binary, real goridge wire protocol, real +`worker.php`, real HTTP over TCP) against the reused `packages/roadrunner/tests/Fixtures/app` fixture +from task 004a — no PSR-7 bridging is faked. + +**Environment note (superseded by verification below):** the task brief assumed no `rr` binary would +be available in this sandbox. Network access was in fact available, so +`composer require --dev spiral/roadrunner-cli` and `vendor/bin/rr get-binary` were run for real and +every requirement below was verified against an actual RoadRunner server process, not just designed +against the skip path. The downloaded `./rr` binary and generated `.rr.yaml` are gitignored +(`.gitignore` additions) and were not committed. + +Fixture app additions (extending, not duplicating, the existing fixture): +- `vendor/autoload.php` — proxies to the monorepo root's own autoloader (the fixture is not a real + Composer install; every `marko/*` class it needs is already autoloadable through the root's path + repositories). Mirrors the pattern already used in `WorkerBootFailureTest`. +- `modules/.gitkeep` — `BasePathResolver::validated()` requires `vendor/`, `app/` *and* `modules/` to + exist under the resolved base path; the fixture previously had no `modules/` directory because + `InProcessRequestHarness` boots via `Application::boot()` directly and never goes through + `BasePathResolver`. The real worker subprocess does. +- `vendor/marko/roadrunner` — a real symlink to the actual `packages/roadrunner` package root (not a + stub), so `.rr.yaml`'s `vendor/marko/roadrunner/worker.php` resolves exactly like a real downstream + install under a Composer path repository. +- `vendor/marko/security` and `vendor/marko/encryption` — new stub modules (composer.json + module.php) + mirroring the existing session/session-file/authentication/config stub pattern, wiring + `CsrfTokenManagerInterface` and `EncryptorInterface` for the CSRF flow test. +- `config/encryption.php` — a fixed, insecure 32-byte key (fixture-only, never a real application). +- `DemoController` gained `GET /csrf/token` and `POST /csrf/submit` (behind `CsrfMiddleware`), plus a + `CsrfTokenManagerInterface` constructor dependency. `/session/write`, `/session/read` and + `/session/throw` were reused unchanged from task 004a/006. + +New test support classes under `packages/roadrunner/tests/Support/`: +- `RoadRunnerServerProcess` — spawns `rr serve` via `proc_open` against a generated temp `.rr.yaml` + pinned to `num_workers: 1` (so every request in every test using it provably lands on the same PHP + worker process — a stronger guarantee than the plan's minimum of three sequential requests) and an + ephemeral free TCP port (collision-safe under `--parallel`). Polls a raw TCP connect until ready or a + 15s timeout, with `stdout`/`stderr` redirected to files (never pipes, to avoid a full-buffer deadlock + while polling). `stop()` terminates the process and cleans up its temp dir. +- `RoadRunnerHttpClient` / `RoadRunnerHttpResponse` — a minimal, dependency-free HTTP/1.1 client over a + raw socket (no new Composer dependency), with explicit per-request `Cookie` header control — including + sending *no* cookie at all, which is exactly what the anonymous-request-in-the-middle isolation + assertions need and a cookie-jar client would make awkward. Decodes `Transfer-Encoding: chunked` + (confirmed empirically that RoadRunner's HTTP plugin always chunks these dynamic PHP responses). +- `SharedRoadRunnerServer` — lazily starts one `rr serve` process on the first test in the file that + needs it, reused by every other test, stopped once via a file-level `afterAll()`. `beforeAll()` was + deliberately avoided (Pest throws `BeforeAllWithinDescribe` inside `describe()`, and an unconditional + `beforeAll` would try to start a server even when the binary is absent); the lazy-start design means a + plain `composer test` run (binary absent, or grouped tests filtered out) never attempts to spawn + anything. + +`Helpers.php` additions: `locateRoadRunnerBinary()`, `roadRunnerSkipReason()`, `sharedRoadRunnerServer()`. + +**Real bug found and fixed in test-only code:** once `spiral/roadrunner-cli` is a dev dependency, +Composer creates its own PHP proxy stub at `vendor/bin/rr` (the *downloader* CLI), which collides with +one of `BinaryLocator`'s own candidate paths — the exact path a real downloaded server binary might +otherwise live at. Reusing `BinaryLocator` naively meant `locateRoadRunnerBinary()` could misidentify the +PHP downloader as the real server (reproduced locally: this caused a 15s `RoadRunnerServerProcess` +timeout instead of a graceful skip). Fixed by having `locateRoadRunnerBinary()` confirm the located path +prints the real server's own `-v` banner ("rr version …", never "RoadRunner CLI …") before trusting it — +test-only code change, `BinaryLocator` itself (production code, owned by an earlier task) was left +untouched as out of scope for task 009. + +The isolation tests ("does not leak session state…", "…authenticated user into an anonymous +request…", "…authenticated user between two different clients") each independently drive the full +A (authenticated, `/session/write`, no cookie) → anonymous (`/session/read`, no cookie at all) → B +(authenticated, `/session/write`, no cookie) sequence per the task's own specified pattern — mirroring +the existing `StateLeakSpikeTest` precedent of duplicating the same three-call sequence across multiple +`it()`s rather than sharing it, so each test is independently meaningful and self-contained. + +`nightly.yml` gained an "Install the RoadRunner binary" step (`vendor/bin/rr get-binary --no-config +--no-interaction`, run after `ramsey/composer-install` and before `composer test:all`) and root +`composer.json` gained `spiral/roadrunner-cli` in `require-dev` (via a real `composer require --dev`, +confirmed resolvable over the network — `composer.lock` is gitignored repo-wide so this doesn't touch a +committed lock file). `tests/CiWorkflowTest.php` gained +`it installs the roadrunner binary in the nightly workflow`, asserting the dependency is declared and +that the install step runs after dependency install and before `composer test:all` — verified RED +(failed without the `nightly.yml`/`composer.json` changes, via `git stash`) then GREEN. + +Verification performed with the real downloaded binary present: +- `packages/roadrunner/tests/Integration/EndToEndTest.php` — 9/9 passing, real assertions, no fakes. +- `packages/roadrunner/tests/` (full package) — 92 passed (up from a baseline of 83). +- `composer test` (excludes `integration-destructive`) — 7078 passed, 0 failures; only the ungrouped + skip-message test runs from this suite, no server ever spawned. +- `composer test:all` (includes `integration-destructive`) — 7090 passed, 0 failures. +- `composer test` re-verified with the `rr` binary hidden — the grouped tests skip individually with the + exact `RoadRunnerException::binaryNotFound()` message; the suite stays green. +- `composer phpstan` — no errors (roadrunner `tests/` is out of PHPStan's configured `paths` scope, + same as every other package's tests; no `src/` files were touched). +- `phpcs` / `php-cs-fixer` — clean on every new/modified file. + +Could not be verified: none — network access was available in this environment, so every requirement, +including the full real-binary happy path, was exercised for real rather than only designed against the +skip branch. diff --git a/.claude/plans/roadrunner/010-docs-and-readme.md b/.claude/plans/roadrunner/010-docs-and-readme.md index eeff21dd..58425c00 100644 --- a/.claude/plans/roadrunner/010-docs-and-readme.md +++ b/.claude/plans/roadrunner/010-docs-and-readme.md @@ -1,6 +1,6 @@ # Task 010: Docs Page and Package README -**Status**: pending +**Status**: completed **Depends on**: 009 **Retry count**: 0 @@ -19,18 +19,18 @@ Write the canonical docs page and the package README. This runs last so both des - The reset lifecycle: what gets reset between requests, in what order, and what that means for anyone writing a stateful singleton. Include the explicit rule — **request-scoped state in a singleton is a cross-user leak under this worker** — with the `Session` and `SessionGuard` fixes from #150 task 009 as the worked example - **Do not write to STDOUT.** `echo`, `var_dump`, `print_r` and `dd`-style debugging corrupt the RoadRunner pipes relay. The worker buffers and discards, but developers need to know why their output vanished - Link the task 005 spike findings page (`roadrunner-state-leaks.md`) as the record of what was investigated - - The known gap that PHPStan does not analyze this package + - **[CORRECTED]** Do NOT document a PHPStan gap. That decision was reversed: task 001 added `packages/roadrunner/src` to `phpstan.neon`, making this the only non-core package under level-6 analysis — deliberately, because it is the one place where a type error becomes a cross-user security bug. Mention that it IS analysed, if anything. - Update the Package Inventory in `.claude/architecture.md` — its own checklist requires this after creating a new package. ## Requirements (Test Descriptions) -- [ ] `it ships a readme following the package readme standards` -- [ ] `it ships a docs page for the roadrunner package` -- [ ] `it documents the unsupported packages and the reason for each` -- [ ] `it documents that file uploads are unsupported` -- [ ] `it documents the session cookie caveat` -- [ ] `it documents the stdout restriction` -- [ ] `it documents the reset lifecycle and the stateful singleton rule` -- [ ] `it lists the package in the architecture package inventory` +- [x] `it ships a readme following the package readme standards` +- [x] `it ships a docs page for the roadrunner package` +- [x] `it documents the unsupported packages and the reason for each` +- [x] `it documents that file uploads are unsupported` +- [x] `it documents the session cookie caveat` +- [x] `it documents the stdout restriction` +- [x] `it documents the reset lifecycle and the stateful singleton rule` +- [x] `it lists the package in the architecture package inventory` ## Acceptance Criteria - All requirements have passing tests @@ -39,3 +39,9 @@ Write the canonical docs page and the package README. This runs last so both des - Code follows code standards ## Implementation Notes +- Added `packages/roadrunner/README.md` (slim pointer per `docs/DOCS-STANDARDS.md`) and `packages/docs-markdown/docs/packages/roadrunner.md` (canonical reference page). +- Test coverage lives in `packages/roadrunner/tests/DocsTest.php`. The docs page was written in full for requirement 2 (it covers installation, `.rr.yaml` defaults/rationale, STDOUT restriction, reset lifecycle + stateful-singleton rule with `Session`/`SessionGuard`/`Inertia::$shared` examples, the session-cookie caveat, unsupported packages, and API reference), so requirements 3-7 passed immediately once their tests were added — over-implementation relative to strict per-requirement TDD, but a natural consequence of writing one coherent prose document rather than fragmenting it into six unrelated edits. +- Docs page cross-links `/docs/packages/database-readwrite/#long-running-processes` and `/docs/packages/roadrunner-state-leaks/`. +- Root README catalog row (`packages/roadrunner/README.md`) already existed from task 001; `bin/check-readme-packages.sh` passes (92 modules aligned). +- Added a new "Application Server" section to the Package Inventory in `.claude/architecture.md`, and corrected the stale "Current implementors" list under "Resettable Singletons and Long-Running Processes" to include `Inertia` (previously missing after #150 task 009 added it). +- Verified `composer test`: 7086 passed (up from the 7078 baseline by the 8 new tests), no failures. diff --git a/.claude/plans/roadrunner/_plan.md b/.claude/plans/roadrunner/_plan.md index 7ab93d3a..92ea515d 100644 --- a/.claude/plans/roadrunner/_plan.md +++ b/.claude/plans/roadrunner/_plan.md @@ -4,7 +4,7 @@ 2026-08-28 ## Status -in_progress +completed ## Objective Create a `marko/roadrunner` driver package that serves a Marko application under RoadRunner — booted once, serving many requests — with a per-request reset lifecycle proven by an empirical spike rather than guessed at, and loud guard rails against packages that are unsafe in worker mode. @@ -98,9 +98,9 @@ Depends on #150 (plan `response-decoration`) | 008 | rr:serve command and .rr.yaml scaffolding | 001 | completed | | 004 | Worker accept loop | 002, 003 | completed | | 005 | State-leak discovery spike | 004a | completed | -| 006 | Per-request reset lifecycle | 004, 005 | pending | -| 009 | End-to-end integration test | 004, 006, 007, 008 | pending | -| 010 | Docs page and package README | 009 | pending | +| 006 | Per-request reset lifecycle | 004, 005 | completed | +| 009 | End-to-end integration test | 004, 006, 007, 008 | completed | +| 010 | Docs page and package README | 009 | completed | Batches: **(1)** 001 → **(2)** 002, 003, 004a, 007, 008 → **(3)** 004, 005 → **(4)** 006 → **(5)** 009 → **(6)** 010. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index bde0ab94..e2ff7de7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -28,5 +28,12 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v3 + # Without this, packages/roadrunner's end-to-end suite finds no `rr` + # binary on this runner and every one of its security-critical + # isolation assertions silently skips — the exact failure mode + # tests/CiWorkflowTest.php guards against below. + - name: Install the RoadRunner binary + run: vendor/bin/rr get-binary --no-config --no-interaction + - name: Run full test suite run: composer test:all diff --git a/composer.json b/composer.json index 00ff3f4a..d5c587ce 100644 --- a/composer.json +++ b/composer.json @@ -489,6 +489,7 @@ "predis/predis": "^2.0", "rector/rector": "^2.3", "slevomat/coding-standard": "^8.26", + "spiral/roadrunner-cli": "^2.7", "spiral/roadrunner-http": "^4.1", "squizlabs/php_codesniffer": "^4.0" }, diff --git a/packages/docs-markdown/docs/packages/inertia.md b/packages/docs-markdown/docs/packages/inertia.md index f28cff54..8dacbf33 100644 --- a/packages/docs-markdown/docs/packages/inertia.md +++ b/packages/docs-markdown/docs/packages/inertia.md @@ -115,6 +115,20 @@ $this->inertia->share([ Shared props are merged after the built-in `errors` and `flash` props, then page props are merged last. +### Long-Running Processes + +`Inertia` implements `Marko\Core\Contracts\ResettableInterface`. In a long-running worker (e.g. Swoole, RoadRunner), call `reset()` between requests to clear `$shared` so props shared by one request's middleware are never present in a later request's Inertia response: + +```php +use Marko\Core\Contracts\ResettableInterface; + +if ($this->inertia instanceof ResettableInterface) { + $this->inertia->reset(); +} +``` + +See [`marko/roadrunner`'s reset lifecycle section](/docs/packages/roadrunner/#reset-lifecycle-and-the-stateful-singleton-rule) for how this runs automatically before each request under that driver. + ### Lazy Props and Partial Reloads Props may be closures. They are evaluated for full loads and for partial reloads that include the prop: @@ -214,12 +228,14 @@ SSR transport failures return `null` from the transport layer so the page can fa ```php namespace Marko\Inertia; +use Marko\Core\Contracts\ResettableInterface; use Marko\Routing\Http\Request; use Marko\Routing\Http\Response; -class Inertia +class Inertia implements ResettableInterface { public function share(array|string $key, mixed $value = null): void; + public function reset(): void; public function flash(string $key, string|array $value): void; public function render(Request $request, string $component, array $props = [], ?string $assetEntry = null): Response; public function location(string $url): Response; diff --git a/packages/docs-markdown/docs/packages/roadrunner.md b/packages/docs-markdown/docs/packages/roadrunner.md new file mode 100644 index 00000000..c56db4ce --- /dev/null +++ b/packages/docs-markdown/docs/packages/roadrunner.md @@ -0,0 +1,153 @@ +--- +title: marko/roadrunner +description: RoadRunner application server driver for Marko — serves your application from one long-running PHP worker process instead of a new process per request. +--- + +RoadRunner application server driver for Marko — serves your application from one long-running PHP worker process instead of spawning a new PHP process per request (the PHP-FPM model). This trades the "everything resets automatically at the end of every request" guarantee of PHP-FPM for lower per-request overhead, in exchange for a set of rules about what a request handler is allowed to hold onto between requests. Read this page before deploying on RoadRunner — the failure mode when those rules are broken is a cross-user data leak, not a crash. + +## Installation + +```bash +composer require marko/roadrunner +composer require spiral/roadrunner-cli --dev +./vendor/bin/rr get-binary +``` + +`marko/roadrunner` requires `marko/core`, `marko/routing`, and `marko/config`. The `spiral/roadrunner-cli` package and `get-binary` step download the actual `rr` server binary, which is not a Composer package itself. + +## Starting the Server + +```bash +marko rr:serve +``` + +On first run, `rr:serve` writes a default `.rr.yaml` next to your project root (skipped if one already exists) and starts the server. The generated config points `server.command` at `php vendor/marko/roadrunner/worker.php` — the thin bootstrap script this package ships that boots your application once and then serves every subsequent request from the same process. + +Pass `--config` to use a config file at a different path: + +```bash +marko rr:serve --config=config/rr.production.yaml +``` + +## The Generated `.rr.yaml` + +```yaml title=".rr.yaml" +version: "3" + +server: + command: "php vendor/marko/roadrunner/worker.php" + relay: pipes + env: + MARKO_BASE_PATH: "/path/to/your/project" + +http: + address: 0.0.0.0:8080 + static: + dir: public + forbid: + - .php + - .htaccess + pool: + max_jobs: 64 + supervisor: + max_worker_memory: 128 +``` + +| Key | Why it's set | +| --- | --- | +| `http.static.dir: public` | Serves files under `public/` directly from RoadRunner instead of routing every asset request through PHP — the same role `.htaccess`/nginx static-file rules play in front of PHP-FPM. `.php` and `.htaccess` are explicitly forbidden from static serving so a request can never fetch application source. | +| `pool.max_jobs: 64` | Recycles each worker after 64 requests. A safety net for any per-request state that isn't cleaned up — including a leak in a third-party package this driver has no visibility into — bounding how many requests a single leaking worker can affect before it is replaced. | +| `pool.supervisor.max_worker_memory: 128` | Kills and replaces a worker once its RSS passes 128 MB. Same rationale as `max_jobs`: bound the blast radius of an undetected leak rather than let one worker grow unbounded for the life of the server. | +| `server.env.MARKO_BASE_PATH` | Tells `worker.php` where your project root is. Required because the worker script ships *inside* this package at `vendor/marko/roadrunner/worker.php`, and under a Composer path repository `vendor/marko/roadrunner` is a symlink — `__DIR__` inside the script would resolve through the symlink into the package's own source tree, not your project. See `Marko\Roadrunner\Worker\BasePathResolver`, which resolves this env var first, then the loaded Composer autoloader's own (never-symlinked) path, before falling back to the current working directory. | + +Both `max_jobs` and `max_worker_memory` exist because a worker running under this driver is not disposable the way a PHP-FPM process is — see [Reset Lifecycle](#reset-lifecycle-and-the-stateful-singleton-rule) below for what the framework itself resets automatically, and why these config values remain a backstop rather than the primary defense. + +## Do Not Write to STDOUT + +RoadRunner's default worker relay is pipes over STDIN/STDOUT, carrying the goridge binary protocol between the worker process and the RoadRunner server. Any unstructured bytes written to STDOUT — `echo`, `print`, `var_dump()`, `print_r()`, `dd()`, an accidental top-level `` output, a framework error handler that echoes — corrupt that protocol stream. + +This driver buffers all output produced while handling a request (`ob_start()` around every request, discarded rather than flushed) specifically so a stray `echo` cannot corrupt the relay — but the practical effect for a developer is that the output simply **vanishes**. There is no error, no warning, and nothing in the response body. If you are debugging with `var_dump()` and see nothing, this is why. + +Use instead: + +- A PSR logger (`Psr\Log\LoggerInterface`, e.g. via `marko/log-file`) — the worker also replaces the framework's own exception handler with one that reports through the logger, or to STDERR when no logger is bound, rather than echoing (`Marko\Roadrunner\Worker\WorkerSafeExceptionHandler`). +- `fwrite(STDERR, ...)` directly. RoadRunner captures a worker's STDERR as worker logs — it is the one stream a worker may write to freely. + +## Reset Lifecycle and the Stateful Singleton Rule + +**Request-scoped state held in a singleton is a cross-user leak under this worker.** PHP-FPM makes this rule invisible: every request gets a fresh process, so a singleton that caches "the current session" or "the currently authenticated user" quietly starts over each time. Under one long-running worker process, the same singleton instance serves every request, so anything it caches from request A is still sitting there when request B — a different user — is handled next, unless something explicitly clears it. + +Before handling each request, `Marko\Roadrunner\Worker\WorkerRequestHandler` resets every already-resolved instance implementing `Marko\Core\Contracts\ResettableInterface`, discovered via `ContainerInterface::resolvedInstances(ResettableInterface::class)` — never a hardcoded per-package list, and never forcing a service to be constructed just to reset it. Instances are reset in a fixed, deterministic order (`ksort()`ed by container binding identifier), so reset behavior never depends on which services happened to be resolved first for a given request. + +The reset runs **before** each request, not after: a request that throws, or a worker that is killed mid-request, must never be allowed to hand stale state forward into the *next* request. If a `reset()` call itself throws, that failure propagates and fails the current request with a 500 rather than being swallowed — silently continuing past a failed reset would itself be the cross-user leak this mechanism exists to prevent. + +**If you write a module with a singleton that caches anything derived from the current request** (the logged-in user, session data, per-request shared view props, an open transaction, …), implement `ResettableInterface` and clear that state in `reset()`. Two worked examples fixed for exactly this reason: + +- **`Session`** and **`SessionGuard`** (`marko/session-file`, `marko/authentication`) — `reset()` clears the cached session id, session data, and cached authenticated user, so the next request starts from a clean slate regardless of which user or session the previous request belonged to. +- **`Inertia::$shared`** (`marko/inertia`) — `share()` merges values into `$shared` for every subsequent `render()` call. Without a reset, data shared by one request's middleware (e.g. the current user) would still be present — and visible — in the next request's Inertia response. `reset()` clears `$shared` between requests. + +`ReadWriteConnection` (`marko/database-readwrite`) also implements `ResettableInterface` for the same underlying reason, covered in more detail in [`marko/database-readwrite`'s Long-Running Processes section](/docs/packages/database-readwrite/#long-running-processes) — sticky-write routing state and any transaction left open by a request that threw before `commit()`/`rollback()` are exactly the kind of per-request state this section describes, just for a database connection rather than a session. + +This driver is the only place in the monorepo that runs `packages/*/src` code under PHPStan level 6 outside `marko/core` — deliberately, since a type error here is a plausible path to a cross-user security bug rather than an ordinary bug. + +For the full mechanical audit behind this section — every container singleton, boot-time binding, class static, superglobal reader, and process-global PHP setting in the monorepo, each given an explicit leak verdict — see [RoadRunner state-leak audit](/docs/packages/roadrunner-state-leaks/). + +## The Session Cookie Caveat + +`Marko\Session\Middleware\SessionMiddleware` only attaches a `Set-Cookie` header to the `Response` when the outgoing session id **differs** from the id the request came in with (or clears the cookie when the session becomes empty). On a repeat request with an unchanged session id, no `Set-Cookie` header is added at all. + +This means the `Response` object is **not** a complete picture of session state on a repeat request — the absence of a `Set-Cookie` header does not mean the session is empty or unused, only that its id did not change during this request. Anything inspecting session state from the response alone (a test assertion, a debugging tool, custom middleware) must account for this instead of treating a missing cookie as "no session." + +## What Is Not Supported + +### `marko/sse` + +Server-sent events stream a response for the life of the connection via `ob_end_flush()`/`flush()` — incompatible with a worker that must return control to the accept loop after each response. `Marko\Roadrunner\GuardRails\UnsafePackageChecker` refuses to boot when `marko/sse` is installed, unless the package is explicitly acknowledged: + +```php title="config/roadrunner.php" +return [ + 'acknowledged_unsafe_packages' => ['marko/sse'], +]; +``` + +Acknowledging the package downgrades the boot-time refusal to a warning on STDERR — it does **not** make SSE work. `Marko\Roadrunner\Http\Psr7ResponseBridge` still throws `StreamingResponseException` per request whenever a `StreamingResponse` reaches it, so an acknowledged install still fails loudly on every SSE route rather than silently truncating the stream. Only acknowledge `marko/sse` if it is installed for a reason unrelated to this worker (e.g. served by a separate FPM pool, a transitive dependency, or a retired endpoint) — not to make SSE routes work under RoadRunner. + +### `marko/debugbar` + +A dev-only tool that reads `$_SERVER` directly (stale under a worker — those values are frozen at whatever the worker process started with, not per-request) and calls `ob_start()` once at boot without a matching per-request `ob_end_*()`, which then captures output from every request that follows in the same worker process. `UnsafePackageChecker` only warns for `marko/debugbar` (never refuses) — do not enable it in a worker-served production environment. + +### File Uploads + +`Marko\Routing\Http\Request` has no equivalent of PHP's `$_FILES`, so PSR-7 uploaded files bridged from an incoming request have nowhere to map. `Marko\Roadrunner\Http\Psr7RequestBridge` throws `UploadedFilesNotSupportedException` immediately when a request carries uploaded files, rather than silently dropping them. Do not submit `multipart/form-data` uploads to a route served through this driver. + +## API Reference + +### `Marko\Roadrunner\Worker\WorkerRequestHandler` + +Drives the RoadRunner accept loop: resets resolved `ResettableInterface` instances, bridges the incoming PSR-7 request, routes it through the already-booted application, bridges the response back, converts any thrown exception into a 500 without leaking details outside `development`, and keeps serving. + +### `Marko\Roadrunner\Worker\BasePathResolver` + +Resolves the project's base path for the packaged `worker.php`, in order: the `MARKO_BASE_PATH` environment variable, then the loaded Composer autoloader's own (symlink-independent) directory, then the current working directory. + +### `Marko\Roadrunner\Http\Psr7RequestBridge` / `Psr7ResponseBridge` + +Convert between PSR-7 messages and `Marko\Routing\Http\Request`/`Response`. `Psr7ResponseBridge` consumes `Response::headerLines()` and uses `withAddedHeader()` for `Set-Cookie` so multiple cookies on one response are preserved rather than overwritten. + +### `Marko\Roadrunner\GuardRails\UnsafePackageChecker` + +| Method | Description | +| --- | --- | +| `check(): array` | Reads `ModuleRepositoryInterface`; throws `UnsafePackageException` if `marko/sse` is installed and not acknowledged via `roadrunner.acknowledged_unsafe_packages`; returns warning strings for an acknowledged `marko/sse` or an installed `marko/debugbar`. | + +### `Marko\Roadrunner\Config\RrYamlTemplate` + +| Method | Description | +| --- | --- | +| `render(string $basePath): string` | Renders the default `.rr.yaml` contents documented above, with `$basePath` interpolated into `server.env.MARKO_BASE_PATH`. | + +### `rr:serve` + +| Option | Default | Description | +| --- | --- | --- | +| `--config` | `.rr.yaml` | Path to the RoadRunner config file. Generated from `RrYamlTemplate` on first run if it doesn't already exist. | diff --git a/packages/roadrunner/README.md b/packages/roadrunner/README.md new file mode 100644 index 00000000..29f8b355 --- /dev/null +++ b/packages/roadrunner/README.md @@ -0,0 +1,23 @@ +# marko/roadrunner + +RoadRunner application server driver for Marko --- serves your application from one long-running PHP worker process instead of spawning a new process per request. + +## Installation + +```bash +composer require marko/roadrunner +composer require spiral/roadrunner-cli --dev +./vendor/bin/rr get-binary +``` + +## Quick Example + +```bash +marko rr:serve +``` + +The first run generates a `.rr.yaml` pointing at `vendor/marko/roadrunner/worker.php`, then starts the server. + +## Documentation + +Full usage, the reset lifecycle, unsupported packages, and API reference: [marko/roadrunner](https://marko.build/docs/packages/roadrunner/) diff --git a/packages/roadrunner/src/Worker/WorkerRequestHandler.php b/packages/roadrunner/src/Worker/WorkerRequestHandler.php index 6a9729a1..4af4ebae 100644 --- a/packages/roadrunner/src/Worker/WorkerRequestHandler.php +++ b/packages/roadrunner/src/Worker/WorkerRequestHandler.php @@ -4,6 +4,8 @@ namespace Marko\Roadrunner\Worker; +use Marko\Core\Container\ContainerInterface; +use Marko\Core\Contracts\ResettableInterface; use Marko\Roadrunner\Http\Psr7RequestBridge; use Marko\Roadrunner\Http\Psr7ResponseBridge; use Marko\Routing\Router; @@ -33,6 +35,7 @@ public function __construct( private Psr7RequestBridge $requestBridge, private Psr7ResponseBridge $responseBridge, private WorkerLogger $logger, + private ContainerInterface $container, private bool $development = false, ) {} @@ -50,6 +53,11 @@ private function handleOne( ob_start(); try { + // Reset before, not after: a request that throws below, or a + // worker killed mid-request, must never leave the *next* + // request with stale state. + $this->resetResolvedServices(); + $request = $this->requestBridge->bridge($psr7Request); $response = $this->router->handle($request); @@ -57,6 +65,9 @@ private function handleOne( } catch (Throwable $throwable) { // Intentional catch-all: one bad request must never kill a // long-running worker. Log it, answer with a 500, keep serving. + // This also covers a reset() failure above — silently + // swallowing that would be a cross-user data leak, so it must + // fail this request exactly like any other thrown error. $this->logger->error($throwable); return $this->errorResponse($throwable); @@ -67,6 +78,30 @@ private function handleOne( } } + /** + * Clears every already-resolved ResettableInterface instance — + * discovered generically via ContainerInterface::resolvedInstances(), + * never by a hardcoded per-package list. Only instances the container + * has already built are touched, since resolvedInstances() never forces + * instantiation: a service the current request never used is never + * constructed just to reset it. + * + * Reset order is fixed and deterministic — ascending by container + * binding identifier — so it never depends on which services happened + * to be resolved first for a given request, in case a future + * resettable's reset() needs to run relative to another's. + */ + private function resetResolvedServices(): void + { + /** @var array $resettables */ + $resettables = $this->container->resolvedInstances(ResettableInterface::class); + ksort($resettables); + + foreach ($resettables as $resettable) { + $resettable->reset(); + } + } + private function errorResponse( Throwable $throwable, ): ResponseInterface { diff --git a/packages/roadrunner/tests/DocsTest.php b/packages/roadrunner/tests/DocsTest.php new file mode 100644 index 00000000..098b3fde --- /dev/null +++ b/packages/roadrunner/tests/DocsTest.php @@ -0,0 +1,97 @@ +toBeTrue(); + + $readme = file_get_contents($readmePath); + + expect($readme)->toContain('# marko/roadrunner') + ->and($readme)->toContain('## Installation') + ->and($readme)->toContain('composer require marko/roadrunner') + ->and($readme)->toContain('## Quick Example') + ->and($readme)->toContain('## Documentation') + ->and($readme)->toContain('https://marko.build/docs/packages/roadrunner/'); + }); + + it('ships a docs page for the roadrunner package', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + + expect(file_exists($docsPath))->toBeTrue(); + + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('title: marko/roadrunner') + ->and($docs)->toContain('## Installation') + ->and($docs)->toContain('rr:serve') + ->and($docs)->toContain('vendor/marko/roadrunner/worker.php') + ->and($docs)->toContain('.rr.yaml') + ->and($docs)->toContain('http.static.dir') + ->and($docs)->toContain('pool.max_jobs') + ->and($docs)->toContain('pool.supervisor.max_worker_memory') + ->and($docs)->toContain('server.env.MARKO_BASE_PATH'); + }); + + it('documents the unsupported packages and the reason for each', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('marko/sse') + ->and($docs)->toContain('acknowledged_unsafe_packages') + ->and($docs)->toContain('StreamingResponseException') + ->and($docs)->toContain('marko/debugbar') + ->and($docs)->toContain('ob_start()'); + }); + + it('documents that file uploads are unsupported', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('File Uploads') + ->and($docs)->toContain('UploadedFilesNotSupportedException') + ->and($docs)->toContain('$_FILES'); + }); + + it('documents the session cookie caveat', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('Session Cookie Caveat') + ->and($docs)->toContain('SessionMiddleware') + ->and($docs)->toContain('Set-Cookie'); + }); + + it('documents the stdout restriction', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('Do Not Write to STDOUT') + ->and($docs)->toContain('var_dump()') + ->and($docs)->toContain('STDERR'); + }); + + it('documents the reset lifecycle and the stateful singleton rule', function (): void { + $docsPath = monorepoRootPath() . '/packages/docs-markdown/docs/packages/roadrunner.md'; + $docs = file_get_contents($docsPath); + + expect($docs)->toContain('ResettableInterface') + ->and($docs)->toContain('cross-user leak') + ->and($docs)->toContain('SessionGuard') + ->and($docs)->toContain('Inertia::$shared') + ->and($docs)->toContain('roadrunner-state-leaks'); + }); + + it('lists the package in the architecture package inventory', function (): void { + $architecturePath = monorepoRootPath() . '/.claude/architecture.md'; + $architecture = file_get_contents($architecturePath); + + expect($architecture)->toContain('## Package Inventory') + ->and($architecture)->toContain('`marko/roadrunner`'); + }); +}); diff --git a/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php b/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php index efb0842b..ef6d57fe 100644 --- a/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php +++ b/packages/roadrunner/tests/Fixtures/app/app/demo/src/Http/Controllers/DemoController.php @@ -7,7 +7,11 @@ use Marko\Authentication\Contracts\GuardInterface; use Marko\Authentication\Exceptions\AuthException; use Marko\Routing\Attributes\Get; +use Marko\Routing\Attributes\Middleware; +use Marko\Routing\Attributes\Post; use Marko\Routing\Http\Response; +use Marko\Security\Contracts\CsrfTokenManagerInterface; +use Marko\Security\Middleware\CsrfMiddleware; use Marko\Session\Contracts\SessionInterface; use Marko\Session\Exceptions\SessionNotStartedException; use Random\RandomException; @@ -26,6 +30,7 @@ class DemoController public function __construct( private readonly SessionInterface $session, private readonly GuardInterface $guard, + private readonly CsrfTokenManagerInterface $csrfTokenManager, ) {} /** @@ -67,6 +72,29 @@ public function throwAfterSessionWrite(): Response throw new RuntimeException('Simulated failure after session write, before normal completion'); } + /** + * Issues a CSRF token tied to the caller's session, so the RoadRunner + * end-to-end suite can drive a real GET-token-then-POST-submit flow + * through one real rr worker process. + */ + #[Get('/csrf/token')] + public function csrfToken(): Response + { + return new Response($this->csrfTokenManager->get()); + } + + /** + * Only reachable once CsrfMiddleware has validated the submitted token + * against the caller's session — an invalid or missing token never + * reaches this method. + */ + #[Post('/csrf/submit')] + #[Middleware(CsrfMiddleware::class)] + public function csrfSubmit(): Response + { + return new Response('csrf-ok'); + } + private function describeState( int $visits, ): string { diff --git a/packages/roadrunner/tests/Fixtures/app/config/encryption.php b/packages/roadrunner/tests/Fixtures/app/config/encryption.php new file mode 100644 index 00000000..993e4980 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/config/encryption.php @@ -0,0 +1,10 @@ + base64_encode(str_repeat('a', 32)), + 'cipher' => 'aes-256-gcm', +]; diff --git a/packages/roadrunner/tests/Fixtures/app/modules/.gitkeep b/packages/roadrunner/tests/Fixtures/app/modules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/autoload.php b/packages/roadrunner/tests/Fixtures/app/vendor/autoload.php new file mode 100644 index 00000000..50344596 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/autoload.php @@ -0,0 +1,15 @@ + app/ -> Fixtures/ -> tests/ -> roadrunner/ -> packages/ -> root +require dirname(__DIR__, 6) . '/vendor/autoload.php'; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/composer.json new file mode 100644 index 00000000..07c322f7 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/composer.json @@ -0,0 +1,14 @@ +{ + "name": "marko/encryption", + "description": "Fixture stub for marko/encryption-openssl used by the RoadRunner end-to-end suite", + "type": "marko-module", + "license": "MIT", + "require": { + "marko/config": "self.version" + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/module.php new file mode 100644 index 00000000..e63257bd --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/encryption/module.php @@ -0,0 +1,14 @@ + [ + EncryptorInterface::class => OpenSslEncryptor::class, + ], +]; diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/roadrunner b/packages/roadrunner/tests/Fixtures/app/vendor/marko/roadrunner new file mode 120000 index 00000000..59307833 --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/roadrunner @@ -0,0 +1 @@ +../../../../.. \ No newline at end of file diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/composer.json b/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/composer.json new file mode 100644 index 00000000..55a208fd --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/composer.json @@ -0,0 +1,16 @@ +{ + "name": "marko/security", + "description": "Fixture stub for marko/security used by the in-process request harness and the RoadRunner end-to-end suite", + "type": "marko-module", + "license": "MIT", + "require": { + "marko/config": "self.version", + "marko/encryption": "self.version", + "marko/session": "self.version" + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/module.php b/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/module.php new file mode 100644 index 00000000..183061be --- /dev/null +++ b/packages/roadrunner/tests/Fixtures/app/vendor/marko/security/module.php @@ -0,0 +1,22 @@ + [ + CsrfTokenManagerInterface::class => function (ContainerInterface $container): CsrfTokenManagerInterface { + return new CsrfTokenManager( + session: $container->get(SessionInterface::class), + encryptor: $container->get(EncryptorInterface::class), + ); + }, + ], +]; diff --git a/packages/roadrunner/tests/Helpers.php b/packages/roadrunner/tests/Helpers.php index c527e5a8..c8e33ef5 100644 --- a/packages/roadrunner/tests/Helpers.php +++ b/packages/roadrunner/tests/Helpers.php @@ -7,7 +7,16 @@ use Closure; use Marko\Core\Module\ModuleManifest; use Marko\Core\Module\ModuleRepositoryInterface; +use Marko\Core\Path\ProjectPaths; +use Marko\Roadrunner\Binary\BinaryLocator; +use Marko\Roadrunner\Exceptions\RoadRunnerException; +use Marko\Roadrunner\Tests\Support\RoadRunnerServerProcess; +use Marko\Roadrunner\Tests\Support\SharedRoadRunnerServer; use Marko\Routing\Http\Request; +use Nyholm\Psr7\ServerRequest; +use Psr\Http\Message\ServerRequestInterface; +use Random\RandomException; +use RuntimeException; use Throwable; /** @@ -103,6 +112,22 @@ function inProcessHarnessRequest( ); } +/** + * Build a PSR-7 ServerRequest against the fixture app's demo routes, + * carrying the given session cookie value under the fixture's configured + * cookie name — the PSR-7-level equivalent of inProcessHarnessRequest(), + * for driving WorkerRequestHandler (which only accepts PSR-7 requests) + * against the same fixture application. + */ +function inProcessHarnessPsr7Request( + string $method, + string $uri, + string $sessionId, +): ServerRequestInterface { + return (new ServerRequest($method, 'https://example.test' . $uri)) + ->withCookieParams([inProcessHarnessSessionCookieName() => $sessionId]); +} + /** * Absolute path to the monorepo root (four levels above this file: * tests/ -> roadrunner/ -> packages/ -> repo root). @@ -112,6 +137,69 @@ function monorepoRootPath(): string return dirname(__DIR__, 3); } +/** + * Locates the real `rr` server binary the same way `rr:serve` does, rooted + * at the monorepo (not the fixture app — the binary is a project-wide dev + * tool, installed once at the repo root via `vendor/bin/rr get-binary`). + * + * `spiral/roadrunner-cli`'s own Composer bin stub is also named `rr` and + * also lives under `vendor/bin/`, one of {@see BinaryLocator}'s candidate + * paths — so once that package is required (as the nightly workflow does), + * a developer who has not yet run `get-binary` has an executable at + * `vendor/bin/rr` that is the *downloader*, not the server. Confirmed here + * by checking that `-v` reports the real server's own version banner + * ("rr version ..."), never roadrunner-cli's ("RoadRunner CLI ..."), + * before trusting the located path — otherwise every e2e test would try to + * "serve" through the wrong binary and time out instead of skipping. + */ +function locateRoadRunnerBinary(): ?string +{ + $binary = (new BinaryLocator(new ProjectPaths(monorepoRootPath())))->locate(); + + if ($binary === null) { + return null; + } + + $version = trim((string) shell_exec(escapeshellarg($binary) . ' -v 2>&1')); + + return str_starts_with($version, 'rr version') ? $binary : null; +} + +/** + * The exact explanation `rr:serve` itself gives a developer when the binary + * is missing, reused here so the end-to-end suite's skip message and the + * command's own error message can never drift apart. + */ +function roadRunnerSkipReason(): string +{ + $exception = RoadRunnerException::binaryNotFound(); + + return $exception->getMessage() . ' ' . $exception->getSuggestion(); +} + +/** + * The one real `rr serve` process shared by every end-to-end test in the + * file that calls this — see {@see SharedRoadRunnerServer}. Every caller is + * expected to have already skipped when {@see locateRoadRunnerBinary} + * returns null, so reaching this function with no binary available is a + * test-authoring mistake rather than an expected runtime state. + * + * @throws RandomException|RuntimeException + */ +function sharedRoadRunnerServer(): RoadRunnerServerProcess +{ + $binary = locateRoadRunnerBinary(); + + if ($binary === null) { + throw new RuntimeException( + 'sharedRoadRunnerServer() requires the RoadRunner binary; callers must skip when ' + . 'locateRoadRunnerBinary() returns null instead of reaching this point.', + ); + } + + return SharedRoadRunnerServer::get($binary, inProcessHarnessFixturePath()); +} + /** * Parse a module.php file's `singletons` declaration into the short * (unqualified) identifiers it registers, used to mechanically cross-check diff --git a/packages/roadrunner/tests/Integration/EndToEndTest.php b/packages/roadrunner/tests/Integration/EndToEndTest.php new file mode 100644 index 00000000..d0e878e3 --- /dev/null +++ b/packages/roadrunner/tests/Integration/EndToEndTest.php @@ -0,0 +1,152 @@ +toContain('RoadRunner binary not found') + ->toContain('rr get-binary') + ->and($binary === null || (is_file($binary) && is_executable($binary)))->toBeTrue(); +}); + +it('serves a successful http response through a real roadrunner process', function (): void { + $response = sharedRoadRunnerServer()->client()->get('/session/read'); + + expect($response->statusCode)->toBe(200) + ->and($response->body)->toContain('visits=0') + ->and($response->body)->toContain('user=guest'); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('preserves a session across two requests from the same client', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $first = $client->get('/session/write'); + $cookie = $first->cookiePair(); + $second = $client->get('/session/read', $cookie); + + expect($cookie)->not->toBeNull() + ->and($first->body)->toContain('visits=1') + ->and($second->body)->toContain('visits=1'); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('does not leak session state between two different clients', function (): void { + $client = sharedRoadRunnerServer()->client(); + + // Three sequential requests through the one shared worker: an + // authenticated user, then a client presenting no cookie at all, then a + // second, distinct authenticated user. + $userA = $client->get('/session/write'); + $anonymous = $client->get('/session/read'); + $userB = $client->get('/session/write'); + + expect($userA->body)->toContain('visits=1') + ->and($anonymous->body)->toContain('visits=0') + ->and($userB->body)->toContain('visits=1'); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('does not leak the authenticated user into an anonymous request', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $userA = $client->get('/session/write'); + $anonymous = $client->get('/session/read'); + $userB = $client->get('/session/write'); + + expect($userA->body)->toContain('user=1') + ->and($anonymous->body)->toContain('user=guest') + ->and($userB->body)->toContain('user=1'); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('does not leak the authenticated user between two different clients', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $userA = $client->get('/session/write'); + $anonymous = $client->get('/session/read'); + $userB = $client->get('/session/write'); + + expect($userA->body)->toContain('user=1') + ->and($anonymous->body)->toContain('user=guest') + ->and($userB->body)->toContain('user=1') + ->and($userA->body)->not->toBe($userB->body); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('sets a session cookie on the first request and not on the second', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $first = $client->get('/session/read'); + $cookie = $first->cookiePair(); + $second = $client->get('/session/read', $cookie); + + expect($first->setCookie)->not->toBeNull() + ->and($cookie)->not->toBeNull() + ->and($second->setCookie)->toBeNull(); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('passes a csrf protected form submission', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $tokenResponse = $client->get('/csrf/token'); + $cookie = $tokenResponse->cookiePair(); + $submission = $client->post('/csrf/submit', ['_token' => $tokenResponse->body], $cookie); + + expect($cookie)->not->toBeNull() + ->and($submission->statusCode)->toBe(200) + ->and($submission->body)->toBe('csrf-ok'); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); + +it('returns a five hundred and keeps serving after a request throws', function (): void { + $client = sharedRoadRunnerServer()->client(); + + $failed = $client->get('/session/throw'); + $recovered = $client->get('/session/read'); + + expect($failed->statusCode)->toBe(500) + ->and($recovered->statusCode)->toBe(200); +}) + ->skip(fn (): bool => locateRoadRunnerBinary() === null, roadRunnerSkipReason()) + ->group('integration-destructive'); diff --git a/packages/roadrunner/tests/Support/RoadRunnerHttpClient.php b/packages/roadrunner/tests/Support/RoadRunnerHttpClient.php new file mode 100644 index 00000000..1b862a38 --- /dev/null +++ b/packages/roadrunner/tests/Support/RoadRunnerHttpClient.php @@ -0,0 +1,154 @@ +send('GET', $path, $cookie); + } + + /** + * @param array $formFields + * + * @throws RuntimeException + */ + public function post( + string $path, + array $formFields, + ?string $cookie = null, + ): RoadRunnerHttpResponse { + return $this->send('POST', $path, $cookie, http_build_query($formFields)); + } + + /** + * @throws RuntimeException + */ + private function send( + string $method, + string $path, + ?string $cookie, + ?string $body = null, + ): RoadRunnerHttpResponse { + $socket = @fsockopen($this->host, $this->port, $errno, $errstr, self::TIMEOUT_SECONDS); + + if ($socket === false) { + throw new RuntimeException("Unable to connect to $this->host:$this->port: $errstr"); + } + + fwrite($socket, $this->buildRequest($method, $path, $cookie, $body)); + + $raw = ''; + while (!feof($socket)) { + $raw .= fread($socket, self::READ_CHUNK_BYTES); + } + + fclose($socket); + + return $this->parseResponse($raw); + } + + private function buildRequest( + string $method, + string $path, + ?string $cookie, + ?string $body, + ): string { + $headers = [ + "$method $path HTTP/1.1", + "Host: $this->host", + 'Connection: close', + ]; + + if ($cookie !== null) { + $headers[] = "Cookie: $cookie"; + } + + if ($body !== null) { + $headers[] = 'Content-Type: application/x-www-form-urlencoded'; + $headers[] = 'Content-Length: ' . strlen($body); + } + + return implode("\r\n", $headers) . "\r\n\r\n" . ($body ?? ''); + } + + private function parseResponse( + string $raw, + ): RoadRunnerHttpResponse { + $separatorPosition = strpos($raw, "\r\n\r\n"); + $rawHeaders = $separatorPosition === false ? $raw : substr($raw, 0, $separatorPosition); + $rawBody = $separatorPosition === false ? '' : substr($raw, $separatorPosition + 4); + + $headerLines = explode("\r\n", $rawHeaders); + $statusLine = array_shift($headerLines) ?? ''; + preg_match('#^HTTP/\d\.\d\s+(\d+)#', $statusLine, $matches); + + $chunked = array_any( + $headerLines, + static fn (string $line): bool => stripos($line, 'Transfer-Encoding:') === 0 + && stripos($line, 'chunked') !== false, + ); + + // Reversed so the first match is the last Set-Cookie in wire order: + // when a response carries several, the last one wins. + $setCookieLine = array_find( + array_reverse($headerLines), + static fn (string $line): bool => stripos($line, 'Set-Cookie:') === 0, + ); + + return new RoadRunnerHttpResponse( + statusCode: isset($matches[1]) ? (int) $matches[1] : 0, + body: $chunked ? $this->decodeChunkedBody($rawBody) : $rawBody, + setCookie: $setCookieLine === null + ? null + : trim(substr($setCookieLine, strlen('Set-Cookie:'))), + ); + } + + private function decodeChunkedBody( + string $body, + ): string { + $decoded = ''; + $offset = 0; + + while (($lineEnd = strpos($body, "\r\n", $offset)) !== false) { + $chunkSize = hexdec(trim(explode(';', substr($body, $offset, $lineEnd - $offset))[0])); + + if ($chunkSize <= 0) { + break; + } + + $decoded .= substr($body, $lineEnd + 2, $chunkSize); + $offset = $lineEnd + 2 + $chunkSize + 2; + } + + return $decoded; + } +} diff --git a/packages/roadrunner/tests/Support/RoadRunnerHttpResponse.php b/packages/roadrunner/tests/Support/RoadRunnerHttpResponse.php new file mode 100644 index 00000000..5f03cd83 --- /dev/null +++ b/packages/roadrunner/tests/Support/RoadRunnerHttpResponse.php @@ -0,0 +1,33 @@ +setCookie === null) { + return null; + } + + return explode(';', $this->setCookie, 2)[0]; + } +} diff --git a/packages/roadrunner/tests/Support/RoadRunnerServerProcess.php b/packages/roadrunner/tests/Support/RoadRunnerServerProcess.php new file mode 100644 index 00000000..906666bf --- /dev/null +++ b/packages/roadrunner/tests/Support/RoadRunnerServerProcess.php @@ -0,0 +1,217 @@ +host = self::DEFAULT_HOST; + $this->port = self::findFreePort($this->host); + + $this->workDir = sys_get_temp_dir() . '/marko-roadrunner-e2e-' . bin2hex(random_bytes(8)); + mkdir($this->workDir, 0755, true); + + $this->configPath = $this->workDir . '/.rr.yaml'; + $this->stdoutPath = $this->workDir . '/stdout.log'; + $this->stderrPath = $this->workDir . '/stderr.log'; + + file_put_contents($this->configPath, $this->renderConfig()); + } + + /** + * @throws RuntimeException + */ + public function start(): void + { + $process = proc_open( + [$this->binary, 'serve', '-c', $this->configPath], + [ + 0 => ['pipe', 'r'], + 1 => ['file', $this->stdoutPath, 'w'], + 2 => ['file', $this->stderrPath, 'w'], + ], + $pipes, + $this->basePath, + ); + + if ($process === false) { + throw new RuntimeException('Failed to start the rr serve process'); + } + + fclose($pipes[0]); + $this->process = $process; + + $this->waitUntilReady(); + } + + public function stop(): void + { + if ($this->process !== null) { + proc_terminate($this->process); + proc_close($this->process); + $this->process = null; + } + + $this->removeWorkDir(); + } + + public function client(): RoadRunnerHttpClient + { + return new RoadRunnerHttpClient($this->host, $this->port); + } + + /** + * @throws RuntimeException + */ + private function waitUntilReady(): void + { + $deadline = microtime(true) + self::READY_TIMEOUT_SECONDS; + + // A refused connection is the expected, repeated steady state while + // rr is still starting up — silenced here rather than with '@' so + // PHPUnit's own error handler (which does not honour '@') cannot + // turn every poll attempt before the server is ready into a warning. + set_error_handler(static fn (): bool => true); + + try { + while (microtime(true) < $deadline) { + $connection = fsockopen( + $this->host, + $this->port, + $errno, + $errstr, + self::READY_PROBE_TIMEOUT_SECONDS, + ); + + if ($connection !== false) { + fclose($connection); + + return; + } + + usleep(self::READY_PROBE_INTERVAL_MICROSECONDS); + } + } finally { + restore_error_handler(); + } + + $stderr = is_file($this->stderrPath) ? file_get_contents($this->stderrPath) : ''; + $this->stop(); + + throw new RuntimeException( + "rr serve did not start listening on $this->host:$this->port within " + . self::READY_TIMEOUT_SECONDS . " seconds.\nstderr:\n$stderr", + ); + } + + private function renderConfig(): string + { + $host = $this->host; + $port = $this->port; + $basePath = $this->basePath; + $workers = self::WORKER_COUNT; + + // An absolute path to both the PHP binary and the worker script: + // rr resolves the command relative to wherever it was itself + // launched from, which is not reliably this test's own cwd. + $workerCommand = PHP_BINARY . ' ' . $basePath . '/vendor/marko/roadrunner/worker.php'; + + return <<workDir)) { + return; + } + + // Explicit, not glob('*'): the config file is dotfile-named + // (.rr.yaml) and glob's '*' does not match leading dots. + foreach ([$this->configPath, $this->stdoutPath, $this->stderrPath] as $file) { + if (is_file($file)) { + unlink($file); + } + } + + rmdir($this->workDir); + } +} diff --git a/packages/roadrunner/tests/Support/SharedRoadRunnerServer.php b/packages/roadrunner/tests/Support/SharedRoadRunnerServer.php new file mode 100644 index 00000000..bec47c9a --- /dev/null +++ b/packages/roadrunner/tests/Support/SharedRoadRunnerServer.php @@ -0,0 +1,46 @@ +start(); + } + + return self::$instance; + } + + public static function stopIfStarted(): void + { + self::$instance?->stop(); + self::$instance = null; + } +} diff --git a/packages/roadrunner/tests/Worker/FaultyResettable.php b/packages/roadrunner/tests/Worker/FaultyResettable.php new file mode 100644 index 00000000..eab382d7 --- /dev/null +++ b/packages/roadrunner/tests/Worker/FaultyResettable.php @@ -0,0 +1,24 @@ +resetCount++; + + if ($this->onReset !== null) { + ($this->onReset)(); + } + } +} diff --git a/packages/roadrunner/tests/Worker/StubResettableContainer.php b/packages/roadrunner/tests/Worker/StubResettableContainer.php new file mode 100644 index 00000000..ed333ec9 --- /dev/null +++ b/packages/roadrunner/tests/Worker/StubResettableContainer.php @@ -0,0 +1,74 @@ + $resolved + */ + public function __construct( + private readonly array $resolved = [], + ) {} + + /** + * @throws RuntimeException + */ + public function get(string $id): never + { + $this->getCallCount++; + + throw new RuntimeException("Unexpected container access for '$id' in test."); + } + + public function has(string $id): bool + { + return false; + } + + public function singleton(string $id): void {} + + public function instance( + string $id, + object $instance, + ): void {} + + /** + * @throws RuntimeException + */ + public function call(Closure $callable): never + { + throw new RuntimeException('Unexpected container call() in test.'); + } + + /** + * @return array + */ + public function resolvedInstances(?string $interface = null): array + { + if ($interface === null) { + return $this->resolved; + } + + return array_filter( + $this->resolved, + static fn (object $instance): bool => $instance instanceof $interface, + ); + } +} diff --git a/packages/roadrunner/tests/Worker/WorkerRequestHandlerResetTest.php b/packages/roadrunner/tests/Worker/WorkerRequestHandlerResetTest.php new file mode 100644 index 00000000..04839b6b --- /dev/null +++ b/packages/roadrunner/tests/Worker/WorkerRequestHandlerResetTest.php @@ -0,0 +1,302 @@ +router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $app->container, + ); + + $handler->run(); + + $bodies = array_map( + static fn (ResponseInterface $response): string => (string) $response->getBody(), + $psr7Worker->responses, + ); + + // Interleave: authenticated -> anonymous -> a different session. + // The anonymous request in the middle is where a stale cached + // identity would do the most damage, and an A -> B sequence alone + // would not catch it. + expect($bodies[0])->toContain('visits=1') + ->toContain('user=1') + ->and($bodies[1])->toContain('visits=0') + ->toContain('user=guest') + ->and($bodies[2])->toContain('visits=1') + ->toContain('user=1'); + }); + + it('isolates session state between two sequential requests', function (): void { + $app = Application::boot(inProcessHarnessFixturePath()); + $psr7Worker = new FakePsr7Worker([ + inProcessHarnessPsr7Request('GET', '/session/write', inProcessHarnessSessionId()), + inProcessHarnessPsr7Request('GET', '/session/read', inProcessHarnessSessionId()), + inProcessHarnessPsr7Request('GET', '/session/write', inProcessHarnessSessionId()), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $app->router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $app->container, + ); + + $handler->run(); + + $bodies = array_map( + static fn (ResponseInterface $response): string => (string) $response->getBody(), + $psr7Worker->responses, + ); + + expect($bodies[0])->toContain('visits=1') + ->and($bodies[1])->toContain('visits=0') + ->and($bodies[2])->toContain('visits=1'); + }); + + it('isolates the authenticated user between two sequential requests', function (): void { + $app = Application::boot(inProcessHarnessFixturePath()); + $psr7Worker = new FakePsr7Worker([ + inProcessHarnessPsr7Request('GET', '/session/write', inProcessHarnessSessionId()), + inProcessHarnessPsr7Request('GET', '/session/read', inProcessHarnessSessionId()), + inProcessHarnessPsr7Request('GET', '/session/write', inProcessHarnessSessionId()), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $app->router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $app->container, + ); + + $handler->run(); + + $bodies = array_map( + static fn (ResponseInterface $response): string => (string) $response->getBody(), + $psr7Worker->responses, + ); + + expect($bodies[0])->toContain('user=1') + ->and($bodies[1])->toContain('user=guest') + ->and($bodies[2])->toContain('user=1'); + }); + + it('resets a resolved resettable service between requests', function (): void { + $router = new Router(new FakeRouteMatcher(), new NullContainer()); + $resettable = new RecordingResettable(); + $container = new StubResettableContainer(['service' => $resettable]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + new ServerRequest('GET', 'https://example.test/two'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($resettable->resetCount)->toBe(2); + }); + + it('skips a resettable service that the container never resolved', function (): void { + $router = new Router(new FakeRouteMatcher(), new NullContainer()); + $container = new StubResettableContainer([]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($psr7Worker->responses)->toHaveCount(1) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(404); + }); + + it('does not instantiate a service that the request never used', function (): void { + $router = new Router(new FakeRouteMatcher(), new NullContainer()); + $resettable = new RecordingResettable(); + $container = new StubResettableContainer(['service' => $resettable]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($container->getCallCount)->toBe(0); + }); + + it('resets before the request rather than after', function (): void { + /** @var list $log */ + $log = []; + $matcher = new FakeRouteMatcher(onMatch: function () use (&$log): null { + $log[] = 'route'; + + return null; + }); + $router = new Router($matcher, new NullContainer()); + $resettable = new RecordingResettable(onReset: function () use (&$log): void { + $log[] = 'reset'; + }); + $container = new StubResettableContainer(['service' => $resettable]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($log)->toBe(['reset', 'route']); + }); + + it('still resets after a request throws', function (): void { + $matcher = new FakeRouteMatcher(onMatch: function (string $method, string $path): ?MatchedRoute { + if ($path === '/boom') { + throw new RuntimeException('request one exploded'); + } + + return null; + }); + $router = new Router($matcher, new NullContainer()); + $resettable = new RecordingResettable(); + $container = new StubResettableContainer(['service' => $resettable]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/boom'), + new ServerRequest('GET', 'https://example.test/two'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($resettable->resetCount)->toBe(2); + }); + + it('fails the request loudly when a reset cannot be performed', function (): void { + $router = new Router(new FakeRouteMatcher(), new NullContainer()); + $container = new StubResettableContainer(['service' => new FaultyResettable()]); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + development: true, + ); + + $handler->run(); + + $body = (string) $psr7Worker->responses[0]->getBody(); + + expect($psr7Worker->responses)->toHaveCount(1) + ->and($psr7Worker->responses[0]->getStatusCode())->toBe(500) + ->and($body)->toContain('reset failed for FaultyResettable'); + }); + + it('performs resets in a deterministic order', function (): void { + /** @var list $log */ + $log = []; + $container = new StubResettableContainer([ + 'zeta' => new RecordingResettable(onReset: function () use (&$log): void { + $log[] = 'zeta'; + }), + 'alpha' => new RecordingResettable(onReset: function () use (&$log): void { + $log[] = 'alpha'; + }), + 'mid' => new RecordingResettable(onReset: function () use (&$log): void { + $log[] = 'mid'; + }), + ]); + $router = new Router(new FakeRouteMatcher(), new NullContainer()); + $psr7Worker = new FakePsr7Worker([ + new ServerRequest('GET', 'https://example.test/one'), + new ServerRequest('GET', 'https://example.test/two'), + ]); + $handler = new WorkerRequestHandler( + psr7Worker: $psr7Worker, + router: $router, + requestBridge: new Psr7RequestBridge(), + responseBridge: new Psr7ResponseBridge(), + logger: new WorkerLogger(), + container: $container, + ); + + $handler->run(); + + expect($log)->toBe(['alpha', 'mid', 'zeta', 'alpha', 'mid', 'zeta']); + }); +}); diff --git a/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php b/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php index 8ab48c59..3b66618c 100644 --- a/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php +++ b/packages/roadrunner/tests/Worker/WorkerRequestHandlerTest.php @@ -28,6 +28,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -49,6 +50,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -72,6 +74,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -94,6 +97,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), development: false, ); @@ -125,6 +129,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -148,6 +153,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -172,6 +178,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $handler->run(); @@ -194,6 +201,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: new WorkerLogger(), + container: new NullContainer(), ); $levelBefore = ob_get_level(); diff --git a/packages/roadrunner/worker.php b/packages/roadrunner/worker.php index 779f3fa8..dabf1729 100644 --- a/packages/roadrunner/worker.php +++ b/packages/roadrunner/worker.php @@ -92,6 +92,7 @@ requestBridge: new Psr7RequestBridge(), responseBridge: new Psr7ResponseBridge(), logger: $workerLogger, + container: $app->container, development: $development, ); diff --git a/tests/CiWorkflowTest.php b/tests/CiWorkflowTest.php index 0df14f3e..3bb1b6d6 100644 --- a/tests/CiWorkflowTest.php +++ b/tests/CiWorkflowTest.php @@ -45,11 +45,8 @@ it('pins actions to a major version rather than a floating ref', function () use ($ci, $nightly): void { preg_match_all('/uses: (\S+)/', $ci . $nightly, $matches); - expect($matches[1])->not->toBeEmpty(); - - foreach ($matches[1] as $action) { - expect($action)->toMatch('/@v\d+$/'); - } + expect($matches[1])->not->toBeEmpty() + ->and($matches[1])->each->toMatch('/@v\d+$/'); }); it('runs the destructive group on a schedule instead of on every PR', function () use ($ci, $nightly): void { @@ -61,6 +58,26 @@ ->and($ci)->not->toContain('test:all'); }); +it('installs the roadrunner binary in the nightly workflow', function () use ($nightly): void { + // Without this, packages/roadrunner's end-to-end suite finds no `rr` + // binary on the nightly runner and every one of its security-critical + // isolation assertions silently skips, defeating the plan's own stated + // mitigation of running "locally and nightly" — this assertion exists + // so a future edit cannot quietly drop the step and reintroduce that. + $composer = json_decode(file_get_contents(dirname(__DIR__) . '/composer.json'), true); + + $dependenciesInstalledAt = strpos($nightly, 'ramsey/composer-install'); + $binaryInstalledAt = strpos($nightly, 'vendor/bin/rr get-binary'); + $testsRunAt = strpos($nightly, 'composer test:all'); + + expect($composer['require-dev'])->toHaveKey('spiral/roadrunner-cli') + ->and($dependenciesInstalledAt)->not->toBeFalse() + ->and($binaryInstalledAt)->not->toBeFalse() + ->and($testsRunAt)->not->toBeFalse() + ->and($binaryInstalledAt)->toBeGreaterThan($dependenciesInstalledAt) + ->and($binaryInstalledAt)->toBeLessThan($testsRunAt); +}); + it('exposes composer phpstan and composer ci scripts the workflow depends on', function (): void { $composer = json_decode(file_get_contents(dirname(__DIR__) . '/composer.json'), true); @@ -83,8 +100,9 @@ ]; foreach ($brokenFixtures as $fixture) { - expect(file_exists(dirname(__DIR__) . '/' . $fixture))->toBeTrue("$fixture should still exist"); - expect($csFixer)->toContain(str_replace('packages/', '', $fixture)); + expect(file_exists(dirname(__DIR__) . '/' . $fixture)) + ->toBeTrue("$fixture should still exist") + ->and($csFixer)->toContain(str_replace('packages/', '', $fixture)); } expect($phpcs)->toContain('src/Broken/'); From 004903c5e7e7cf6fdee1cf1d762ea95767f3bb38 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Sat, 29 Aug 2026 08:39:41 -0400 Subject: [PATCH 3/4] test: guard against fixture files being silently excluded by gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL --- .../plans/roadrunner/006-reset-lifecycle.md | 14 ++- .gitignore | 15 ++- tests/FixtureTrackingTest.php | 105 ++++++++++++++++++ 3 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 tests/FixtureTrackingTest.php diff --git a/.claude/plans/roadrunner/006-reset-lifecycle.md b/.claude/plans/roadrunner/006-reset-lifecycle.md index 7d9d3682..50e00257 100644 --- a/.claude/plans/roadrunner/006-reset-lifecycle.md +++ b/.claude/plans/roadrunner/006-reset-lifecycle.md @@ -73,7 +73,7 @@ warns that `marko/debugbar` does not belong in a worker-served environment. Do not add reset wiring for them. ## Requirements (Test Descriptions) -- [x] `it resets every service identified by the spike between requests` +- [x] `it resets both session and identity state across interleaved requests` - [x] `it isolates session state between two sequential requests` - [x] `it isolates the authenticated user between two sequential requests` - [x] `it resets a resolved resettable service between requests` @@ -93,6 +93,14 @@ not add reset wiring for them. ## Implementation Notes +**Requirement renamed.** This task was originally specified as `it resets every +service identified by the spike between requests`. The post-implementation +standards pass renamed it to `it resets both session and identity state across +interleaved requests`, because "the spike" is a development artifact that does +not exist anywhere in the repository — the old name described nothing to anyone +reading the test later. The requirement text above was updated to match the +test that actually ships, so the two cannot drift. + `WorkerRequestHandler` (`packages/roadrunner/src/Worker/WorkerRequestHandler.php`) now takes a required `ContainerInterface $container` and, inside `handleOne()`, calls a private `resetResolvedServices()` **before** bridging/routing the @@ -110,8 +118,8 @@ No `get()`/`call()` is ever invoked to build something just to reset it. cases were updated to pass `container: new NullContainer()` (already returns `[]` from `resolvedInstances()`, so their behavior is unchanged). -One requirement — `it resets every service identified by the spike between -requests` — legitimately required the full mechanism (container injection, +One requirement — `it resets both session and identity state across +interleaved requests` — legitimately required the full mechanism (container injection, pre-request reset placement, generic `ResettableInterface` filtering) to go RED→GREEN. The remaining nine requirements (isolation, resolved/unresolved discovery, no forced instantiation, before-not-after ordering, reset-after- diff --git a/.gitignore b/.gitignore index d0d7eeab..06e720e3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,15 @@ composer.lock vendor/ -# The RoadRunner test harness boots a real Marko application from a fixture -# project tree, which must contain a directory literally named `vendor/` for -# module discovery to find it. The unanchored `vendor/` rule above matches at -# any depth, so without this negation the fixture's modules are never committed -# and the harness silently finds zero modules on a fresh clone or in CI. -!/packages/roadrunner/tests/Fixtures/app/vendor/ +# Test fixtures that model a real Marko project tree must contain a directory +# literally named `vendor/`, because that is what module discovery looks for. +# The unanchored `vendor/` rule above matches at any depth, so without this +# negation those fixture modules are never committed: the tests pass locally +# off untracked files and the fixture silently has zero modules on a fresh +# clone or in CI. Applies to every package's fixtures, not one, so a new +# fixture does not have to rediscover this. `tests/FixtureTrackingTest.php` +# fails the build if any fixture file becomes unreachable this way. +!/packages/*/tests/Fixtures/**/vendor/ # `rr:serve` and `vendor/bin/rr get-binary` both write into the project root: # a downloaded platform-specific binary and a generated default config. Local diff --git a/tests/FixtureTrackingTest.php b/tests/FixtureTrackingTest.php new file mode 100644 index 00000000..f21db125 --- /dev/null +++ b/tests/FixtureTrackingTest.php @@ -0,0 +1,105 @@ + repository root. + return dirname(__DIR__); +} + +function fixtureFiles(): array +{ + $paths = []; + + foreach (glob(monorepoRoot() . '/packages/*/tests/Fixtures', GLOB_ONLYDIR) ?: [] as $fixtureRoot) { + // Symlinks are deliberately not followed: the roadrunner fixture links + // vendor/marko/roadrunner back at the package itself, so descending + // through it would recurse without end. + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($fixtureRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY, + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isLink() || !$file->isFile()) { + continue; + } + + $paths[] = $file->getPathname(); + } + } + + sort($paths); + + return $paths; +} + +/** + * @param list $paths + * @return list the subset git would refuse to track + */ +function ignoredByGit(array $paths): array +{ + // --no-index also reports paths that are tracked today but would be ignored + // if re-added, which is the latent form of this bug. + $process = proc_open( + ['git', 'check-ignore', '--stdin', '--no-index'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + monorepoRoot(), + ); + + if (!is_resource($process)) { + return []; + } + + fwrite($pipes[0], implode("\n", $paths) . "\n"); + fclose($pipes[0]); + + $ignored = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return array_values(array_filter(explode("\n", trim($ignored)))); +} + +it('keeps every package test fixture reachable by git', function (): void { + $files = fixtureFiles(); + $ignored = ignoredByGit($files); + + expect($files) + ->not->toBeEmpty() + ->and($ignored) + ->toBeEmpty(sprintf( + 'These fixture files are excluded by .gitignore, so they will not exist on a fresh ' + . "clone and the fixtures that depend on them will silently have nothing in them:\n %s\n\n" + . 'Add a negation to the root .gitignore re-including the path, the way ' + . '"!/packages/*/tests/Fixtures/**/vendor/" already does for fixture vendor directories.', + implode("\n ", $ignored), + )); +}); From dbe2f719d5bb5f03bc02e1b4fa77ff54999c0389 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Sat, 29 Aug 2026 08:45:51 -0400 Subject: [PATCH 4/4] chore(roadrunner): declare the package as type marko-module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL --- packages/roadrunner/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roadrunner/composer.json b/packages/roadrunner/composer.json index 368d782a..e594439a 100644 --- a/packages/roadrunner/composer.json +++ b/packages/roadrunner/composer.json @@ -2,7 +2,7 @@ "name": "marko/roadrunner", "description": "RoadRunner application server driver for Marko Framework", "license": "MIT", - "type": "library", + "type": "marko-module", "require": { "php": "^8.5", "marko/config": "self.version",