Make routing coroutine-safe and tighten the routing API#255
Conversation
Router::match and the wildcard branch in Http::runInternal both wrote to the shared Route singleton (setMatchedPath, path) on every request. Under Swoole coroutines the Route is shared across in-flight requests, so concurrent requests could observe each other's matched path. - Router::match now returns [Route, matchedPath] instead of mutating the Route. A new Router::setFallback slot replaces Http::$wildcardRoute, so the method-agnostic catch-all flows through the same matching path as any other route. - Route::matchedPath / setMatchedPath / getMatchedPath are removed. - Http::execute takes the matched path as a parameter; runInternal threads it through. Public Http::match keeps its ?Route shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
k6 benchmark
|
Greptile SummaryThis PR makes routing coroutine-safe by eliminating all in-place mutation of shared state during request dispatch:
Confidence Score: 4/5The change is safe to merge; the coroutine-safety fix is well-targeted and the new test covers the sub-request isolation guarantee. The rewrite is large but focused — the core dispatch path, the wildcard fallback, and all test call-sites were updated consistently. The one inconsistency found (bare -> vs ?-> in the OPTIONS group-hooks branch) is a readability issue only; it cannot cause a null dereference at runtime because the enclosing foreach already guarantees $match is non-null. The public API break on Router::match and Http::match return types is intentional and documented, and all in-tree callers were updated. No files require special attention beyond the single null-safety inconsistency on line 555 of src/Http/Http.php. Important Files Changed
Reviews (10): Last reviewed commit: "Rename frameLocals to locals" | Re-trigger Greptile |
The Http instance is shared across coroutines, so $this->route and $this->matchedPath would race the same way Route's mutable fields did. Store them in the per-request context() container instead, which is already request-scoped post-#254. getRoute()/setRoute() now read/write through the context too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the [Route, matchedPath] tuple with a readonly Router\Result value object so callers get named, typed access instead of positional unpacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
$matchedPath is already the prepared form (the key from Router::$routes), so re-preparing it just returned the same string. Pass it straight to Route::getPathValues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move the value object to Utopia\Http\RouteMatch (top-level), since 'Match' is reserved by PHP 8.0+. RouteMatch is short, clear, and doesn't shadow the keyword. - Rename matchedPath -> path on the DTO; the field name is qualified by the surrounding RouteMatch context. - Inline matchInternal: public Http::match now returns ?RouteMatch directly instead of indirecting through a private helper. - Http::execute now takes a RouteMatch (route + matched path together) instead of separate args, so callers can't pass mismatched pairs. - Cache the whole RouteMatch under 'match' in the per-request context; keep 'route' set too for downstream injection compat. - Add per-property docblocks on RouteMatch. - Update tests to wrap raw Routes in RouteMatch when calling execute(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the API with how callers think about it: Route is a definition, RouteMatch is the immutable result of matching, execute() is the verb that ties them together (match -> resolve -> run). - Http::execute now takes (Request, Response) and does match + dispatch internally, including OPTIONS/HEAD handling and 404 fallback. Replaces the prior shape that required callers to pre-build a RouteMatch. - Http::match becomes stateless: drop the $fresh / context-cache that silently returned the previous request's match when a caller invoked execute() multiple times with different requests. - runInternal collapses to: pre-checks (compression, request hooks, static files) + delegate to execute(). - Update tests: hand-built routes now register via Http::get/post/etc., set $_SERVER['REQUEST_URI'] before execute(), and use Http::setAllowOverride(true) for tests that re-register the same path. - Update Router::match callers to unwrap ->route from RouteMatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The matched-template string was purely instrumental — its only job was to look up Route::pathParams[$template] so callers could resolve URL segments into a name->value map. Now Router::match resolves the params itself and stores them on RouteMatch directly, so dispatch is just `$match->params` with no second-stage resolution. - RouteMatch.path: string -> RouteMatch.params: array<string, string>. - Route::getPathValues renamed to Route::resolveParams; takes a URL string instead of a Request (the resolution doesn't need anything else from the request). - Router::match calls resolveParams at match time. Static and wildcard matches pass [] for params. - Http::execute drops getPathValues call; reads $match->params directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The matched route is owned by the routing pipeline and lives in the per-request context. setRoute let arbitrary code overwrite it post-match without invalidating any other state — a footgun under coroutines and not used in production. Drop it; getRoute() remains as a read-only view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The supported way to consume the matched route is via the 'route'
injection inside hooks/actions:
Http::init()
->inject('route')
->action(function (?Route $route) { ... });
getRoute() was a convenience accessor on the shared Http instance.
Reading mutable per-request state through a method on a shared object
encourages racy patterns under coroutines (e.g. caching a Route
reference, calling getRoute() outside a request scope). Drop it; tests
that needed the matched route now consume it via the injection or via
the RouteMatch returned from match() directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop internal narrative about coroutine safety, mutation-vs-immutability, and "we used to do X." Comments now describe what each public surface does for someone calling it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
run() is the top-level request lifecycle (compression, request hooks, static files, match, dispatch, telemetry) — wired into the server adapter. execute() is the re-entrant dispatch primitive — match + handler + hooks only — for sub-requests from inside a handler (e.g. GraphQL resolvers synthesizing Request/Response pairs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
match() no longer writes to context — that was leaking the inner
match into the outer's context whenever execute() was called for a
sub-request, breaking outer-request shutdown hooks doing
->inject('route') and the http.route telemetry attribute.
execute() now sets context['route'] right before dispatching and
restores the prior value (or null) in a finally clause, so nested
execute() calls don't trample each other's frame.
Adds testSubrequestRestoresOuterRoute as a regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The save/restore pattern was just bookkeeping around a shared mutable slot — anything else writing to context['route'] during dispatch would break the restore, and a missed restore in any branch leaks the inner match into the outer frame. Drop context['route'] entirely. Pass the dispatch frame's Route through to getArguments and special-case the 'route' injection there. Each dispatch frame (including sub-requests via execute()) carries its own matched Route as a parameter; nested calls can't trample each other because there's no shared state to trample. Telemetry in run() now reads the outer match by calling match() once locally — match() is pure and cheap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assertEquals -> assertSame in testSubrequestRestoresOuterRoute. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route::getPath() returns '' for the wildcard route by construction
(registered as 'new Route("", "")'). Emitting that as the OTel
http.route attribute would set the attribute to an empty string —
different from "unset" in OTel semantics. Coerce to null at the
attribute boundary so http.route is unset for wildcard / no-match
requests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wildcard fallback was modeled as `new Route('', '')` — a Route with
empty method and path, which are meaningless for a method-agnostic
catch-all. The empty strings were sentinels that leaked into
Route::getPath() at runtime for any handler doing inject('route').
Model honestly: Http::wildcard() returns a Hook (the parent class —
action + params + injections + groups). Router::\$wildcard is now
?Hook. RouteMatch::\$route is typed Hook so it can carry either —
consumers wanting Route-only fields (getMethod, getPath) check
instanceof Route first.
Behavior: inject('route') now correctly returns null inside a wildcard
handler (it never had a Route to begin with). Telemetry's http.route
attribute is unset for wildcard matches, matching OTel semantics.
Group hooks don't fire for wildcard matches (it has no groups beyond
'*'); the global init/shutdown blocks still run because there's no
Route::getHook() opt-out for the wildcard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit e89d58f.
Mirrors the 'route' injection: hooks can do ->inject('params') to receive
the resolved path params (array<string, string>) for the current dispatch
frame. Skips shared context for the same reason 'route' does — nested
execute() calls (sub-request dispatch) can't trample each other.
Refactor: collapse the per-name special-cases into a frame-local map for
clarity. Both 'route' and 'params' come from the dispatch frame; the map
makes that explicit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Routing on this branch was unsafe under coroutines:
Router::matchwrotesetMatchedPathonto the shared Route on every request, the wildcard branch inHttp::runInternalcalled\$route->path()per request, and\$this->routelived as a property on the shared Http instance. Two requests in flight at once could observe each other's matched path.This PR makes the entire routing path immutable at runtime and lands a smaller, sharper API along the way.
Routing changes
Router::matchreturns?RouteMatch. Afinal readonlyvalue object carrying(Route \$route, array \$params)— the matched Route and path params already resolved from the request URL. No more in-place mutation of Route to record the matched template.Route::matchedPath/setMatchedPath/getMatchedPath. Path-param resolution moved toRoute::resolveParams(string \$url, string \$matchedTemplate), called once at match time byRouter::match.Http::wildcard()now registers viaRouter::setWildcard(\$route). The special-case fallback branch inHttp::runInternalis gone;Router::matchconsults the wildcard slot as a final fallback so wildcard requests follow the same dispatch path as any other route.'route'injection is frame-local. No\$this->routeproperty, nocontext['route']slot. Each dispatch frame carries its own matched Route as a parameter throughgetArguments; nestedexecute()calls (sub-request dispatch) can't trample each other.API changes
Http::execute(Request, Response)is now the dispatch primitive: match + handler + hooks, plus OPTIONS/HEAD handling and 404 fallback. Public — safe to call from inside a handler for sub-request dispatch (e.g. GraphQL resolvers synthesizing API calls). Skips request-level setup that belongs toHttp::run().Http::run(Request, Response)stays the top-level request entry point: compression setup, request hooks, static files, dispatch, telemetry. Wired into the server adapter as before.Http::match(Request)stateless and pure: returns?RouteMatch, writes nothing.Http::setRoute(),Http::getRoute(),Http::matchInternal(), the\$freshparameter onmatch(),Route::setMatchedPath/getMatchedPath. The supported way to consume the matched route inside a hook/action is the'route'injection.Route::getPathValues(Request)→Route::resolveParams(string \$url, string \$matchedTemplate). Takes a URL string instead of a Request — it never needed anything else from the Request.Downstream migration
This breaks the Appwrite GraphQL resolver pattern (`Resolvers::resolve` in `Appwrite\GraphQL\Resolvers`). The migration is small but not zero:
The new GraphQL flow is shorter than the old one — execute does its own match, no need to thread the Route through.
Wildcard handlers
Previously the wildcard route's
getPath()returned the current request's URL (e.g./unknown/path) becauseHttp::runInternalmutated the shared wildcard Route on every request. That mutation was the original race. It's gone, so wildcard handlers reading\$route->getPath()now see''(the route's registration template, which is empty by construction).If you have a wildcard handler that needs the matched URL, switch to:
```php
Http::wildcard()
->inject('request')
->action(function ($request) {
$url = $request->getURI();
});
```
The matched URL is a per-request concern and belongs on the Request, not the Route.
For OTel: wildcard / no-match telemetry now correctly emits
http.routeas unset (rather than as an empty string) per OTel semantic conventions.Known follow-ups (out of scope)
Hook::setParamValuemutates static hooks per request. Same bug class as the Route mutation we fixed. Hooks live inself::\$init/\$shutdown/\$errorsetc., shared across coroutines. Worth a separate PR.Router::\$routes,\$wildcard,Http::\$initetc. mutate via static methods. Fine at boot, race city if anyone callsHttp::get(...)mid-request. Worth a class-doc note and possibly a frozen-after-start flag.Test plan
vendor/bin/phpstan analysecleanvendor/bin/phpunit --exclude-group e2e— 102 tests, 238 assertions, 0 failures (only the docker-required e2e tests error, unrelated)testSubrequestRestoresOuterRoutecovers nested execute() not leaking the inner match🤖 Generated with Claude Code