Skip to content

Refactor: Coroutine-safe request dispatch#249

Open
loks0n wants to merge 7 commits intomainfrom
refactor/coroutine-safe-dispatch
Open

Refactor: Coroutine-safe request dispatch#249
loks0n wants to merge 7 commits intomainfrom
refactor/coroutine-safe-dispatch

Conversation

@loks0n
Copy link
Copy Markdown
Contributor

@loks0n loks0n commented Apr 24, 2026

Summary

Under Swoole coroutines, the Http singleton and Router-registered Route objects are shared by every concurrent request. Several fields on those shared objects were being written per-request — Http::$route, Route::$matchedPath, and a ->path($requestUri) mutation on the wildcard Route — creating races where one coroutine's state could clobber another's between match and execution.

This PR extracts per-request state into a new Dispatcher and an immutable RouteMatch value object, and leaves Http / Router / Route carrying only bootstrap-time configuration. Hook registries move to a new dedicated Hooks class. Router public API collapses to a single matching entry point.

  • Http no longer holds $this->route; the matched route flows through the per-request DI container (already coroutine-local via the Swoole adapters' Coroutine::getContext()).
  • Route is frozen after registration: setMatchedPath / getMatchedPath are deleted, and the wildcard route is no longer mutated with the request path on each hit.
  • Router::matchRequest(Request) is the sole public matching entry point, returning final readonly class RouteMatch. The string-based Router::match(string, string) is gone, and Router::matchRoute(string, string) is private.
  • Method-agnostic wildcard moves into Router via Router::setWildcard(Route). Http::$wildcardRoute / Http::getWildcardRoute() deleted; Http::wildcard() still returns a Route, now registered with Router::setWildcard under the hood. Dispatcher no longer special-cases wildcard fallback.
  • Hooks owns the six lifecycle-hook registries (init/shutdown/options/error/start/request) that used to live as protected static fields on Http. Http::init() etc. delegate to Hooks::init() etc. — same public API, cleaner internals.

Breaking changes

Public API changes. Deprecation shims are kept for one minor release on the Http surface; the Router surface breaks outright (matching semantics change shape, no backwards-compat returnable).

Removed / deprecated Replacement
Route::setMatchedPath() / Route::getMatchedPath() Removed. Read $routeMatch->routeKey or $routeMatch->preparedPath from the per-request routeMatch resource.
Http::getRoute() (deprecation shim) $http->getResource('routeMatch')?->route — or inject route / routeMatch into your handler.
Http::setRoute(Route) (deprecation shim) setRequestResource('routeMatch', fn() => new RouteMatch(...)). Tests/legacy only; the new dispatcher populates this automatically.
Http::match(Request, bool $fresh) (deprecation shim) Router::matchRequest($request) — returns ?RouteMatch. The $fresh argument is now a no-op.
Http::execute(Route, Request, Response) (deprecation shim) Internal dispatch moved to Dispatcher.
Router::match(string, string): ?Route Removed. Use Router::matchRequest($request).
Router::matchRoute(string, string): ?RouteMatch Now private. Use Router::matchRequest($request).
Http::$wildcardRoute / Http::getWildcardRoute() Removed. Wildcard lives on Router now. Http::wildcard() is unchanged. If you need to inspect or reset the wildcard directly, call Router::setWildcard() / include it in Router::reset().
Http::$init / $shutdown / $options / $errors / $startHooks / $requestHooks (previously protected static) Removed. Read Hooks::$init, Hooks::$shutdown, Hooks::$options, Hooks::$errors, Hooks::$start, Hooks::$request instead. Http::init() / shutdown() / options() / error() / onStart() / onRequest() are unchanged — they now delegate to Hooks.

Migration guide

1. Reading the matched route inside a handler / hook

Before:

Http::init()->action(function () use ($utopia) {
    $route = $utopia->getRoute();
    // ...
});

After — preferred (via injection):

Http::init()
    ->inject('route')
    ->action(function (?Route $route) {
        // ...
    });

Or via the request resource:

$match = $http->getResource('routeMatch'); // ?RouteMatch
$route = $match?->route;

2. Per-request route label mutations (e.g. Appwrite's ->label('router', true))

Route is now frozen after registration. Mutating a shared Route's labels from a handler is a concurrency hazard — the write lands on a process-global object seen by every coroutine.

  • Prefer registration-time labels. If the label is known when the route is defined, set it there: Http::get('/x')->label('router', true).
  • Per-request state goes in your own DI resource. If you truly need to tag the current request at runtime, register a resource in your own container — $http->setResource('myRequestTag', fn () => new MyTag()) — and mutate that. It lives in the per-request container (backed by Coroutine::getContext() in Swoole) and cannot clobber peer coroutines.

3. Matching a route outside the dispatcher

Before:

$route = Router::match($method, $path);
if ($route !== null) {
    $matchedPath = $route->getMatchedPath();
}

After — Router::matchRequest handles URL parsing and HEAD-to-GET normalisation; the returned RouteMatch carries the per-request facts (the shared Route is never mutated):

$match = Router::matchRequest($request);
if ($match !== null) {
    $route    = $match->route;
    $urlPath  = $match->urlPath;
    $routeKey = $match->routeKey;     // router table key (e.g. "/users/:::")
    $prepared = $match->preparedPath;
}

If you have only a method + path string and no Request, construct a minimal Request (the FPM adapter's Request reads $_SERVER['REQUEST_METHOD'] / $_SERVER['REQUEST_URI']). Router::matchRoute(string, string) is no longer public.

4. Wildcard inspection

Before:

$wildcard = Http::getWildcardRoute();

After — the wildcard lives on Router now. Http::wildcard() still returns the Route for registration-time chaining. If you need to inspect or override it at runtime, use Router::setWildcard($route).

5. Direct access to Http hook statics from subclasses

// Before
foreach (self::$errors as $error) { ... }

// After
foreach (Hooks::$errors as $error) { ... }

Registration continues to work through either surface — Http::init() and Hooks::init() are interchangeable.

6. If you subclassed Http to override runInternal / execute

Dispatch now lives in Utopia\Http\Dispatcher. For now, open an issue if you depended on overriding these.

7. Http::match($request, fresh: false) caching semantics

The per-singleton cache of the last-matched route is gone — it was the core concurrency bug. match() now always returns a fresh result and the $fresh argument is ignored.

Concurrency properties after this change

  • Route is never mutated after Router::addRoute() (or Router::setWildcard()) returns.
  • Http holds no per-request state.
  • RouteMatch is final readonly class — immutable at the type level, not by convention.
  • Per-request mutable state (matched route, error payload) lives in the per-request DI container. Swoole adapters back that container with Coroutine::getContext(); the FPM adapter has one request per process and is trivially safe.

Test plan

  • vendor/bin/phpunit — 113 tests, 322 assertions, all green.
  • vendor/bin/phpstan analyse — no errors at level 7.
  • vendor/bin/pint --test — clean.
  • vendor/bin/rector process --dry-run — clean.
  • Swoole e2e suite — needs a Swoole-enabled runner; CI will cover.
  • True concurrent-coroutine regression test — not yet added.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactoring

    • Redesigned HTTP request processing architecture to ensure proper request isolation and prevent state leakage between requests.
    • Updated router matching system with immutable results for safer and more reliable request-to-route resolution.
  • Tests

    • Added comprehensive test coverage for request dispatching, routing lifecycle, and error handling scenarios.

Extract per-request state from the Http singleton into a Dispatcher and
immutable RouteMatch. Under Swoole the Http, Router, and Route objects
are shared across every concurrent coroutine; previously matched-route
state lived on those shared objects and could be clobbered by a peer
coroutine between match and execute.

- Http no longer holds \$this->route; the matched route flows through
  the per-request DI container.
- Route is frozen after registration: setMatchedPath/getMatchedPath are
  gone, and the wildcard route is no longer mutated with the request
  path on each hit.
- Router::matchRoute() returns an immutable RouteMatch (final readonly)
  instead of writing back onto the shared Route.
- RequestContext provides a per-request overlay for label overrides so
  consumers that tagged the current route (e.g. \$route->label('router',
  true)) have a safe migration target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 24, 2026

k6 benchmark

Throughput Requests Fail rate p50 p95
5736 req/s 401598 0% 7.18 ms 15 ms

Adds three test files (28 cases, 67 assertions) focused on the
no-mutation guarantees that motivate the refactor.

- DispatcherTest: sequential FPM dispatch verifies request resources
  (route/routeMatch/context) are populated, RequestContext is fresh per
  request, the wildcard Route is never mutated, parameterized routes
  don't bleed state between requests, and hook/error/OPTIONS/HEAD
  lifecycle flows.
- RouteMatchTest: field access, readonly-class reflection check, and a
  runtime assertion that fields cannot be reassigned.
- RouterMatchRouteTest: matchRoute return shape, exact/param/wildcard
  branches, explicit no-mutation snapshot across repeated matches,
  distinct RouteMatch instance per call, and the deprecated match()
  shim still returns a Route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR extracts per-request mutable state out of the Http singleton and shared Route objects into a new Dispatcher + immutable RouteMatch value object, correctly eliminating the Swoole coroutine race conditions. The core concurrency fix is sound, and the wildcard fallback (previously on Http::$wildcardRoute) is now properly threaded through Router::$wildcard so Router::matchRequest() handles it transparently.

One breaking-change gap needs attention before merge:

  • Router::match(string $method, string $path): ?Route has been outright removed with no deprecation shim in Router, despite the PR description stating a shim exists. Simultaneously, the migration guide directs callers to Router::matchRoute($method, $path) as the low-level replacement, but that method is private. Callers who only have a bare method+path string (no Request object) have no public escape hatch and will hit a fatal error following the documented migration path.

Confidence Score: 4/5

Safe to merge after addressing the Router::match() missing shim / matchRoute() visibility gap.

The concurrency fix itself is correct and well-tested. The single P1 finding is a public-API breakage: Router::match() was removed without a shim and Router::matchRoute() is private despite being documented as the replacement, leaving callers with a bare method+path string with no public migration path.

src/Http/Router.php — missing Router::match() deprecation shim and matchRoute() visibility.

Important Files Changed

Filename Overview
src/Http/Dispatcher.php New per-request dispatcher that correctly isolates mutable state from the shared Http singleton; cleanly handles init/shutdown/options/error hook lifecycle.
src/Http/Hooks.php New process-global hook registry extracted from Http; clean separation of bootstrap-time configuration from per-request dispatch.
src/Http/Http.php Http singleton cleaned of per-request state; deprecation shims added for getRoute/setRoute/match/execute; several methods promoted from protected to public to support Dispatcher access.
src/Http/Router.php Router::match() removed with no deprecation shim; matchRoute() made private while migration guide documents it as a public replacement — both create fatal errors for existing callers.
src/Http/RouteMatch.php New immutable value object carrying per-request match facts; correctly declared final readonly.
src/Http/Route.php setMatchedPath/getMatchedPath removed; Route is now immutable after registration, eliminating the shared-state race.
tests/DispatcherTest.php Comprehensive new test suite covering per-request isolation guarantees; good regression tests for wildcard immutability and hook lifecycle.
tests/RouterMatchRouteTest.php New test suite for Router::matchRequest covering exact, parameterized, prefix-wildcard, and method-agnostic wildcard cases; validates RouteMatch immutability.
tests/HttpTest.php Existing test updated to reflect that match() no longer caches — fresh: false now returns a fresh result, which is the correct concurrent-safe behaviour.
tests/RouteMatchTest.php Unit tests for RouteMatch value object; verifies readonly enforcement, field accessors, and wildcard token round-trip.
tests/RouterTest.php Existing router tests adapted to call Router::matchRequest via a private helper since Router::match() is no longer public.

Reviews (6): Last reviewed commit: "Collapse Router surface and fold wildcar..." | Re-trigger Greptile

Comment thread src/Http/RequestContext.php Outdated
Comment thread src/Http/Http.php Outdated
loks0n and others added 3 commits April 24, 2026 15:01
The per-request label-override overlay isn't needed. Route labels
should be set at registration time; any per-request mutable state a
consumer needs can go in their own DI-container resource. Keeping a
dedicated class for this was speculative scope.

- Delete src/Http/RequestContext.php and its test file.
- Dispatcher no longer constructs a RequestContext or registers a
  `context` request resource.
- Http::execute shim drops the same wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hook arrays (init/shutdown/options/error/start/request) lived as
protected statics on Http, and the previous refactor added six public
getInitHooks()/getShutdownHooks()/etc. accessors purely so Dispatcher
could read them. That's a leaky API — internal process-global state
promoted to public surface for the sake of one internal collaborator.

Move the registries to a dedicated Hooks class (mirrors how Router
owns its own static state). Http::init/shutdown/options/error/onStart/
onRequest remain as thin delegators for backwards compat; the six
getXxxHooks accessors are gone. Dispatcher reads Hooks::\$init etc.
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dispatcher::execute hoisted \$this->request->getParams() into a local
before any init hooks fired, then passed that stale array to every
subsequent getArguments() call. That broke the init-hook-mutates-params
contract: if an init hook applied filter/rewrite via
\$request->setQueryString(), the route action and shutdown hooks would
still see the pre-mutation view.

The old Http::execute called \$request->getParams() inline at each call
site specifically to preserve this ordering. Restore that behaviour.

Adds two regression tests: init-hook mutation visible to the route
action, and init-hook mutation visible to a shutdown hook's params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
loks0n added a commit to appwrite/appwrite that referenced this pull request Apr 24, 2026
Follows utopia-php/http#249 migration guide to drop usage of the shims
kept for one-minor-release compat. Affected call sites switch to:

- Handler / hook context: inject 'route' (Route|null from the per-request
  DI container) instead of $utopia->getRoute().
- Resource factories (init/resources/request.php): Router::matchRoute()
  returning ?RouteMatch instead of $utopia->match($request).
- Error path in app/http.php: $app->getResource('routeMatch')?->route,
  guarded with try/catch for the pre-dispatch error case.
- GraphQL resolver inner-request pattern (Resolvers.php): capture
  getResource('routeMatch') as the original, Router::matchRoute() for
  the inner URL, rebind 'route' / 'routeMatch' on the resolver container
  in finally instead of setRoute($original). Http::execute() shim is
  retained — the guide explicitly keeps it for this hand-built-route
  case.

Redundant re-match in shared/api.php storage-cache branch removed; the
injected $route is already the current one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
loks0n and others added 2 commits April 24, 2026 17:34
Router::matchRoute() takes (method, path) strings, forcing every caller
to hand-roll URL parsing and HEAD-to-GET normalisation. The library
already does this internally in Dispatcher::handle; the matching
boilerplate should not be the caller's problem.

Router::matchRequest(Request) wraps matchRoute and handles both. The
deprecated Http::match() shim now delegates to it; Dispatcher continues
to call matchRoute directly because it needs the parsed URL in scope
for the wildcard fallback anyway.

Adds three regression tests covering URL extraction, root-URL fallback
when the URI has no path, and HEAD normalisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two public matching entry points (matchRoute + matchRequest) were a
mild smell; the method-agnostic wildcard living as a static singleton
on Http was an outright special case leaking into the Dispatcher.
Address both at once:

- Make Router::matchRoute(string, string) private. Router::matchRequest
  is now the sole public matching API. External callers that used to
  hand-roll parse_url + HEAD normalisation to call matchRoute now
  just pass a Request.
- Delete the deprecated Router::match(string, string): ?Route shim.
- Move wildcard registration into Router via new Router::setWildcard;
  delete Http::\$wildcardRoute and Http::getWildcardRoute. Http::wildcard
  continues to return a Route, just registered on Router.
- Dispatcher's match section collapses from the \$url/\$matchMethod/
  wildcard-fallback block to a single Router::matchRequest call.
- RouterTest/RouterMatchRouteTest migrate to matchRequest via a private
  test helper that sets \$_SERVER and builds an FPM Request.
- Three new tests cover Router::setWildcard semantics (unknown path,
  unknown method, and method-specific precedence over the wildcard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Refactors the HTTP request lifecycle by introducing a per-request Dispatcher class that manages route matching, hook execution, and error handling. Centralizes hook registries into a new Hooks class. Creates an immutable RouteMatch value object for match results. Updates Router::match() to Router::matchRequest() returning RouteMatch instead of mutable Route. Delegates HTTP lifecycle work from Http to Dispatcher.

Changes

Cohort / File(s) Summary
Request Dispatch Architecture
src/Http/Dispatcher.php
New per-request handler executing full lifecycle: compression setup, route matching, init/shutdown hooks, action execution, error handling with hook invocation, and file serving fallback.
Hook Registry
src/Http/Hooks.php
New centralized hook registry with static arrays (init, shutdown, options, errors, start, request) and factory methods for hook registration and group filtering.
Routing & Match Results
src/Http/Router.php, src/Http/RouteMatch.php, src/Http/Route.php
Router adds matchRequest() returning immutable RouteMatch (containing route, urlPath, routeKey, preparedPath); adds wildcard fallback via setWildcard(); removes mutable match() API. Route removes mutable matchedPath state.
HTTP Lifecycle Refactor
src/Http/Http.php
Delegates request handling to Dispatcher, moves hook storage to Hooks, exposes compression/file-serving helper APIs, updates execute()/match()/getRoute() as deprecated compat shims wrapping Dispatcher/Router::matchRequest().
Core Request Dispatch Tests
tests/DispatcherTest.php, tests/RouterMatchRouteTest.php
Comprehensive test coverage for new Dispatcher lifecycle (hooks, error handling, HEAD/OPTIONS semantics, parameter mutation) and Router::matchRequest() (path extraction, wildcard fallback, method normalization).
Routing Tests
tests/RouterTest.php, tests/RouteMatchTest.php, tests/HttpTest.php
Updated existing router tests to use matchRequest() helper; new RouteMatch readonly immutability and field exposure tests; updated HTTP matching test expectations for fresh match behavior.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Http
    participant Dispatcher
    participant Router
    participant Hooks
    participant Action
    
    Client->>Http: run()
    Http->>Dispatcher: new + handle()
    
    Dispatcher->>Router: matchRequest(request)
    Router-->>Dispatcher: RouteMatch|null
    
    alt Route Found
        Dispatcher->>Hooks: invoke init hooks
        Hooks-->>Dispatcher: (results)
        Dispatcher->>Action: execute action
        Action-->>Dispatcher: result
        Dispatcher->>Hooks: invoke shutdown hooks
        Hooks-->>Dispatcher: (results)
    else No Route / Error
        Dispatcher->>Hooks: invoke error hooks
        Hooks-->>Dispatcher: (results)
    end
    
    Dispatcher-->>Http: (lifecycle complete)
    Http-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Meldiron
  • abnegate

Poem

🐰 A dispatcher hops in, so nimble and fleet,
Shepherding hooks through each lifecycle beat.
Routes immutable now, no more tangled state—
The request flows cleanly, decisive and straight!
Bzzzzt! goes the refactor, so fresh and divine! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Refactor: Coroutine-safe request dispatch' accurately reflects the main objective of the changeset, which is to refactor the HTTP request dispatch system to be coroutine-safe by extracting per-request state from shared singletons.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/coroutine-safe-dispatch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread src/Http/Router.php
* call {@see self::matchRequest()} so URL parsing and HEAD normalisation
* are handled consistently.
*/
private static function matchRoute(string $method, string $path): ?RouteMatch
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Router::matchRoute is private but documented as a public replacement

The PR's migration guide explicitly tells callers to use Router::matchRoute($method, $path) as the low-level alternative when no Request object is available, and the breaking-changes table lists Router::match() as having a "deprecation shim" pointing at Router::matchRoute(). But matchRoute is private, so:

  1. Any existing caller of Router::match($method, $path) gets an immediate fatal call-to-undefined-method error (no shim was added to Router).
  2. Developers following the migration guide and trying Router::matchRoute($method, $path) directly will hit a fatal error calling a private method.

The only public entry point is Router::matchRequest(Request $request), which requires wrapping a bare method+path pair in a synthetic Request object. Either add a deprecation shim for Router::match() or make matchRoute public so the migration guide is accurate.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (6)
src/Http/Router.php (2)

182-188: Minor: $current is implicitly scoped to the enclosing function, not the loop.

$current = ($current ?? '') . "{$part}/"; relies on $current being undefined on entry, but because PHP variables leak out of blocks, if matchRoute() were ever refactored to reuse $current earlier in the method, the coalesce would silently carry stale state. An explicit $current = ''; immediately above the foreach makes the intent clearer and is defensive against future edits.

♻️ Suggested tweak
             /**
              * Match wildcard for path segments (e.g. GET /foo/*).
              */
+            $current = '';
             foreach ($parts as $part) {
-                $current = ($current ?? '') . "{$part}/";
+                $current .= "{$part}/";
                 $match = $current . self::WILDCARD_TOKEN;
                 if (array_key_exists($match, self::$routes[$method])) {
                     return new RouteMatch(self::$routes[$method][$match], $path, $match, $match);
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Router.php` around lines 182 - 188, In matchRoute
(src/Http/Router.php) the loop uses $current = ($current ?? '') . "{$part}/";
which relies on $current being undefined and can carry stale state; explicitly
initialize $current = ''; immediately before the foreach over $parts to make the
intent clear and prevent variable leakage, leaving the rest of the loop and the
RouteMatch return logic (new RouteMatch(self::$routes[$method][$match], $path,
$match, $match)) unchanged.

149-200: routeKey and preparedPath are always passed the same value — consider consolidating or clarifying intent.

In every new RouteMatch(...) construction inside matchRoute() (and in the wildcard fallback), the 3rd and 4th arguments are the same $match / WILDCARD_TOKEN string. If these fields are semantically equivalent today, carrying both in RouteMatch is redundant surface area that callers have to reason about. If they are intended to diverge (e.g., routeKey = canonical router-table key vs preparedPath = actual prepared path that matched, which differs for aliases), then passing the same value here silently loses that distinction.

Worth either collapsing to a single field, or documenting on RouteMatch what each is meant to represent so future callers don't treat them as interchangeable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Router.php` around lines 149 - 200, The code currently passes the
same value for the 3rd and 4th RouteMatch constructor args (routeKey and
preparedPath) in matchRoute(), which either hides intended semantics or is
redundant; fix by choosing one of two approaches: (A) collapse the two fields
into one in the RouteMatch class (remove preparedPath or routeKey from the
constructor and update all usages) and update matchRoute() calls (new
RouteMatch(..., $path, $match)), or (B) preserve both semantics by computing and
passing distinct values: keep routeKey as the canonical router table key ($match
or self::WILDCARD_TOKEN) and set preparedPath to the actual normalized/prepared
request path (e.g., the $path after trimming/normalizing or an alias-resolved
value) when constructing RouteMatch in matchRoute(), ensuring all other call
sites handle the new meaning; update RouteMatch docblocks to describe routeKey
vs preparedPath accordingly.
src/Http/Hooks.php (2)

22-40: Public static registries expose mutable state that the docblock says must not be mutated.

The class comment states hooks "must not be mutated after the server starts accepting requests", but declaring the arrays public static means any caller — including handler code running mid-request — can append to or wholesale reassign them. That's effectively the same class of shared-mutable-state hazard this refactor is trying to close on Http/Route.

Given registration already goes through the static factory methods (init(), shutdown(), …), making the arrays protected and exposing read-only accessors for Dispatcher would make the invariant enforceable. The "six getXxx accessors" cost noted in the docblock seems worth paying for a concurrency-safety class.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Hooks.php` around lines 22 - 40, Change the public static hook
registries in class Hooks (currently public static arrays $init, $shutdown,
$options, $errors, $start, $request) to protected static so external code cannot
mutate them directly, and add six read-only accessor methods (e.g. getInit(),
getShutdown(), getOptions(), getErrors(), getStart(), getRequest()) that return
the arrays for use by Dispatcher; update any call sites that read the registries
(such as Dispatcher) to use these new getters and keep existing static factory
registration methods (init(), shutdown(), etc.) as the only mutation
entrypoints.

93-111: Asymmetric group defaults between lifecycle hooks and onStart/onRequest is non-obvious.

init/shutdown/options/error all default to groups(['*']) but onStart/onRequest do not. If this is intentional (e.g., start/request fire unconditionally regardless of group matching), a one-line comment inside the factories would make it explicit and prevent a well-meaning "fix" later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Hooks.php` around lines 93 - 111, The onStart() and onRequest()
factory methods (which create Hook instances and append to self::$start and
self::$request) differ from init/shutdown/options/error which default to
groups(['*']); if this is intentional, add a concise one-line comment inside the
onStart and onRequest factories stating that these hooks run unconditionally (do
not use group matching) so future maintainers don't change behavior —
alternatively, if they should follow the same group-default behavior, set the
created Hook to groups(['*']) before returning; update the Hook creation in
onStart and onRequest accordingly and document the chosen behavior in that
one-line comment.
tests/RouterTest.php (1)

12-28: Consider saving/restoring $_SERVER globals for test isolation.

match() mutates $_SERVER['REQUEST_METHOD'] and $_SERVER['REQUEST_URI'] but tearDown() only resets the Router. HttpTest and DispatcherTest save/restore these in setUp/tearDown — worth doing the same here for consistency so leftover globals don't leak into other suites that happen to run afterwards in the same process.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/RouterTest.php` around lines 12 - 28, Tests mutate
$_SERVER['REQUEST_METHOD'] and $_SERVER['REQUEST_URI'] in the private
match(string $method, string $path): ?Route method but tearDown() only calls
Router::reset(), so restore the original globals to avoid leaking state; add a
setUp() that saves the current $_SERVER entries (or a snapshot of $_SERVER) to a
private property (e.g. $originalServer) and update tearDown() to restore those
saved values before/after calling Router::reset(); reference the match(),
setUp(), tearDown(), Router::reset(), and the
$_SERVER['REQUEST_METHOD']/$_SERVER['REQUEST_URI'] keys when making the changes.
src/Http/Dispatcher.php (1)

51-51: Catch \Throwable for consistency with the rest of this class.

Every other error-catching site in this file (Lines 59, 106, 179, 187, 198) uses \Throwable. Only the onRequest hook block here narrows to \Exception, so a TypeError/AssertionError thrown from a user hook silently bypasses the wildcard error handlers and escapes the dispatcher.

♻️ Proposed change
-        } catch (\Exception $e) {
+        } catch (\Throwable $e) {
             $this->http->setRequestResource('error', fn() => $e);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Dispatcher.php` at line 51, Change the catch in the onRequest hook
from catching \Exception to catching \Throwable so errors like
TypeError/AssertionError don't bypass the dispatcher's wildcard handlers; locate
the catch (\Exception $e) in the onRequest hook inside Dispatcher.php and
replace it with catch (\Throwable $e) while keeping the existing error handling
logic and variable name ($e) intact to match the other catch sites in this
class.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Http/Dispatcher.php`:
- Around line 46-76: After running onRequest hooks and then wildcard error
handlers in the catch block (Hooks::$request / Hooks::$errors) the code
continues into static file serving and route dispatch, which can cause double
responses; modify Dispatcher::handle() to abort further processing if an error
handler already sent a response—either return early from the catch block after
running error handlers or add a guard before static-file serving and route
execution using $this->response->isSent() (same check used in execute()) so that
isFileLoaded(), ->send(), and subsequent route init/execute/shutdown are skipped
when a response is already sent.
- Around line 66-89: The Dispatcher currently checks for static files before
normalizing HEAD requests, so HEAD requests to preloaded files still send
bodies; to fix, obtain and normalize the request method early (call
$this->request->getMethod() and if it's Http::REQUEST_METHOD_HEAD set $method =
Http::REQUEST_METHOD_GET and call $this->response->disablePayload()) before the
isFileLoaded($this->request->getURI()) branch so that when isFileLoaded(...)
triggers the subsequent ->send(...) it will have payloads disabled for HEAD;
update references to $method and keep existing use of isFileLoaded,
getFileContents, send, matchRequest, setRequestResource, etc., unchanged except
for moving the normalization logic earlier in Dispatcher.

---

Nitpick comments:
In `@src/Http/Dispatcher.php`:
- Line 51: Change the catch in the onRequest hook from catching \Exception to
catching \Throwable so errors like TypeError/AssertionError don't bypass the
dispatcher's wildcard handlers; locate the catch (\Exception $e) in the
onRequest hook inside Dispatcher.php and replace it with catch (\Throwable $e)
while keeping the existing error handling logic and variable name ($e) intact to
match the other catch sites in this class.

In `@src/Http/Hooks.php`:
- Around line 22-40: Change the public static hook registries in class Hooks
(currently public static arrays $init, $shutdown, $options, $errors, $start,
$request) to protected static so external code cannot mutate them directly, and
add six read-only accessor methods (e.g. getInit(), getShutdown(), getOptions(),
getErrors(), getStart(), getRequest()) that return the arrays for use by
Dispatcher; update any call sites that read the registries (such as Dispatcher)
to use these new getters and keep existing static factory registration methods
(init(), shutdown(), etc.) as the only mutation entrypoints.
- Around line 93-111: The onStart() and onRequest() factory methods (which
create Hook instances and append to self::$start and self::$request) differ from
init/shutdown/options/error which default to groups(['*']); if this is
intentional, add a concise one-line comment inside the onStart and onRequest
factories stating that these hooks run unconditionally (do not use group
matching) so future maintainers don't change behavior — alternatively, if they
should follow the same group-default behavior, set the created Hook to
groups(['*']) before returning; update the Hook creation in onStart and
onRequest accordingly and document the chosen behavior in that one-line comment.

In `@src/Http/Router.php`:
- Around line 182-188: In matchRoute (src/Http/Router.php) the loop uses
$current = ($current ?? '') . "{$part}/"; which relies on $current being
undefined and can carry stale state; explicitly initialize $current = '';
immediately before the foreach over $parts to make the intent clear and prevent
variable leakage, leaving the rest of the loop and the RouteMatch return logic
(new RouteMatch(self::$routes[$method][$match], $path, $match, $match))
unchanged.
- Around line 149-200: The code currently passes the same value for the 3rd and
4th RouteMatch constructor args (routeKey and preparedPath) in matchRoute(),
which either hides intended semantics or is redundant; fix by choosing one of
two approaches: (A) collapse the two fields into one in the RouteMatch class
(remove preparedPath or routeKey from the constructor and update all usages) and
update matchRoute() calls (new RouteMatch(..., $path, $match)), or (B) preserve
both semantics by computing and passing distinct values: keep routeKey as the
canonical router table key ($match or self::WILDCARD_TOKEN) and set preparedPath
to the actual normalized/prepared request path (e.g., the $path after
trimming/normalizing or an alias-resolved value) when constructing RouteMatch in
matchRoute(), ensuring all other call sites handle the new meaning; update
RouteMatch docblocks to describe routeKey vs preparedPath accordingly.

In `@tests/RouterTest.php`:
- Around line 12-28: Tests mutate $_SERVER['REQUEST_METHOD'] and
$_SERVER['REQUEST_URI'] in the private match(string $method, string $path):
?Route method but tearDown() only calls Router::reset(), so restore the original
globals to avoid leaking state; add a setUp() that saves the current $_SERVER
entries (or a snapshot of $_SERVER) to a private property (e.g. $originalServer)
and update tearDown() to restore those saved values before/after calling
Router::reset(); reference the match(), setUp(), tearDown(), Router::reset(),
and the $_SERVER['REQUEST_METHOD']/$_SERVER['REQUEST_URI'] keys when making the
changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6fd1da5-889c-4090-9151-410702968ab9

📥 Commits

Reviewing files that changed from the base of the PR and between d1eced0 and 1493a84.

📒 Files selected for processing (11)
  • src/Http/Dispatcher.php
  • src/Http/Hooks.php
  • src/Http/Http.php
  • src/Http/Route.php
  • src/Http/RouteMatch.php
  • src/Http/Router.php
  • tests/DispatcherTest.php
  • tests/HttpTest.php
  • tests/RouteMatchTest.php
  • tests/RouterMatchRouteTest.php
  • tests/RouterTest.php
💤 Files with no reviewable changes (1)
  • src/Http/Route.php

Comment thread src/Http/Dispatcher.php
Comment on lines +46 to +76
try {
foreach (Hooks::$request as $hook) {
$arguments = $this->http->getArguments($hook, [], []);
\call_user_func_array($hook->getAction(), $arguments);
}
} catch (\Exception $e) {
$this->http->setRequestResource('error', fn() => $e);

foreach (Hooks::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
try {
$arguments = $this->http->getArguments($error, [], []);
\call_user_func_array($error->getAction(), $arguments);
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
}
}
}
}

if ($this->http->isFileLoaded($this->request->getURI())) {
$time = (60 * 60 * 24 * 365 * 2);

$this->response
->setContentType($this->http->getFileMimeType($this->request->getURI()))
->addHeader('Cache-Control', 'public, max-age=' . $time)
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
->send($this->http->getFileContents($this->request->getURI()));

return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fall-through after onRequest error handling can double-send / dispatch a route.

After catching an onRequest hook error and running wildcard error handlers (which almost always write a response), control continues into static-file serving (Lines 66–76) and then into route matching + execute(). That means the matched route action and its init/shutdown hooks can still run after the request was already rejected, and ->send() can be invoked twice. Consider returning early from the catch block, or guarding the rest of handle() with if ($this->response->isSent()) { return; } — similar to the isSent() check already used at Line 160 inside execute().

🔧 Proposed fix
         } catch (\Exception $e) {
             $this->http->setRequestResource('error', fn() => $e);
 
             foreach (Hooks::$errors as $error) {
                 if (\in_array('*', $error->getGroups())) {
                     try {
                         $arguments = $this->http->getArguments($error, [], []);
                         \call_user_func_array($error->getAction(), $arguments);
                     } catch (\Throwable $e) {
                         throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
                     }
                 }
             }
+
+            return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Dispatcher.php` around lines 46 - 76, After running onRequest hooks
and then wildcard error handlers in the catch block (Hooks::$request /
Hooks::$errors) the code continues into static file serving and route dispatch,
which can cause double responses; modify Dispatcher::handle() to abort further
processing if an error handler already sent a response—either return early from
the catch block after running error handlers or add a guard before static-file
serving and route execution using $this->response->isSent() (same check used in
execute()) so that isFileLoaded(), ->send(), and subsequent route
init/execute/shutdown are skipped when a response is already sent.

Comment thread src/Http/Dispatcher.php
Comment on lines +66 to +89
if ($this->http->isFileLoaded($this->request->getURI())) {
$time = (60 * 60 * 24 * 365 * 2);

$this->response
->setContentType($this->http->getFileMimeType($this->request->getURI()))
->addHeader('Cache-Control', 'public, max-age=' . $time)
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
->send($this->http->getFileContents($this->request->getURI()));

return;
}

$method = $this->request->getMethod();
$this->match = Router::matchRequest($this->request);

$this->http->setRequestResource('route', fn() => $this->match?->route);
$this->http->setRequestResource('routeMatch', fn() => $this->match);

$groups = $this->match?->route->getGroups() ?? [];

if (Http::REQUEST_METHOD_HEAD === $method) {
$method = Http::REQUEST_METHOD_GET;
$this->response->disablePayload();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether Response::send honors disablePayload via an internal flag
# and whether any existing test covers HEAD-against-static-file to document
# pre-existing vs regression behavior.
rg -nP --type=php -C3 '\bdisablePayload\s*\('
rg -nP --type=php -C3 'function\s+send\s*\(' -g 'src/Http/Response*.php'
rg -nP --type=php -C3 'REQUEST_METHOD_HEAD|isFileLoaded' -g 'tests/**'

Repository: utopia-php/http

Length of output: 1850


🏁 Script executed:

sed -n '537,620p' src/Http/Response.php

Repository: utopia-php/http

Length of output: 2812


🏁 Script executed:

sed -n '640,680p' src/Http/Response.php

Repository: utopia-php/http

Length of output: 1057


🏁 Script executed:

sed -n '60,95p' src/Http/Http/Dispatcher.php

Repository: utopia-php/http

Length of output: 131


🏁 Script executed:

# Search for any test that uses HEAD method or tests static files
rg -i "head.*static|static.*head" tests/ --type=php
rg "REQUEST_METHOD_HEAD" tests/ --type=php -B2 -A2
rg "loadFiles|isFileLoaded" tests/ --type=php -B2 -A2

Repository: utopia-php/http

Length of output: 41


🏁 Script executed:

fd -type f "Dispatcher.php" | head -5

Repository: utopia-php/http

Length of output: 230


🏁 Script executed:

# Find correct path and read the file
DISP_PATH=$(fd -type f "Dispatcher.php" | head -1)
sed -n '60,95p' "$DISP_PATH"

Repository: utopia-php/http

Length of output: 274


🏁 Script executed:

# Search for tests that exercise Dispatcher with static files or HEAD
rg "Dispatcher|loadFiles" tests/ --type=php -l | head -10

Repository: utopia-php/http

Length of output: 84


🏁 Script executed:

fd "Dispatcher.php" --type f | grep -v test

Repository: utopia-php/http

Length of output: 83


🏁 Script executed:

# Once we find it, read the dispatcher code
DISP=$(fd "Dispatcher.php" --type f | grep -v test)
sed -n '60,95p' "$DISP"

Repository: utopia-php/http

Length of output: 1471


🏁 Script executed:

# Check DispatcherTest.php for HEAD or static file tests
head -100 tests/DispatcherTest.php

Repository: utopia-php/http

Length of output: 3219


🏁 Script executed:

# Search for HEAD method tests in DispatcherTest
rg -A10 -B5 "HEAD|head|loadFile" tests/DispatcherTest.php

Repository: utopia-php/http

Length of output: 739


🏁 Script executed:

# Check if Response::send has any other HEAD handling besides disablePayload
rg -C5 "REQUEST_METHOD_HEAD" src/Http/Response.php

Repository: utopia-php/http

Length of output: 41


🏁 Script executed:

# Double-check: does Response have any HEAD-specific logic in send/write methods?
rg "HEAD|head" src/Http/Response.php | grep -i "method\|header"

Repository: utopia-php/http

Length of output: 2886


HEAD requests to preloaded static files still stream the full body.

$this->response->disablePayload() is only invoked at line 88, after the static-file short-circuit at lines 66–76 has already called ->send($fileContents). HEAD requests whose URI matches a file loaded via loadFiles() will emit the body instead of headers-only, violating HTTP HEAD semantics. The existing test testHeadRequestResolvesToGetRouteWithPayloadDisabled covers HEAD for routes but not static files.

Normalize the method before the file-serving branch:

🔧 Proposed fix
+        $method = $this->request->getMethod();
+        if (Http::REQUEST_METHOD_HEAD === $method) {
+            $this->response->disablePayload();
+        }
+
         if ($this->http->isFileLoaded($this->request->getURI())) {
             $time = (60 * 60 * 24 * 365 * 2);
 
             $this->response
                 ->setContentType($this->http->getFileMimeType($this->request->getURI()))
                 ->addHeader('Cache-Control', 'public, max-age=' . $time)
                 ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
                 ->send($this->http->getFileContents($this->request->getURI()));
 
             return;
         }
 
-        $method = $this->request->getMethod();
         $this->match = Router::matchRequest($this->request);
 
         $this->http->setRequestResource('route', fn() => $this->match?->route);
         $this->http->setRequestResource('routeMatch', fn() => $this->match);
 
         $groups = $this->match?->route->getGroups() ?? [];
 
         if (Http::REQUEST_METHOD_HEAD === $method) {
             $method = Http::REQUEST_METHOD_GET;
-            $this->response->disablePayload();
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Dispatcher.php` around lines 66 - 89, The Dispatcher currently
checks for static files before normalizing HEAD requests, so HEAD requests to
preloaded files still send bodies; to fix, obtain and normalize the request
method early (call $this->request->getMethod() and if it's
Http::REQUEST_METHOD_HEAD set $method = Http::REQUEST_METHOD_GET and call
$this->response->disablePayload()) before the
isFileLoaded($this->request->getURI()) branch so that when isFileLoaded(...)
triggers the subsequent ->send(...) it will have payloads disabled for HEAD;
update references to $method and keep existing use of isFileLoaded,
getFileContents, send, matchRequest, setRequestResource, etc., unchanged except
for moving the normalization logic earlier in Dispatcher.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant