Make routing coroutine-safe and tighten the routing API#255
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 the routing path coroutine-safe by eliminating all per-request mutations on shared objects:
Confidence Score: 3/5Mostly safe to merge, but wildcard-route handlers that read getPath() to discover the matched URL will silently receive an empty string after this change. The wildcard route is now created as Route('', '') and its path is never updated to the actual request URL — the old $route->path($path) mutation was removed as part of the coroutine-safety work. Any handler that injects the route and calls getPath() to discover which URL hit the wildcard will now always get ''. This is a concrete behavioral regression not covered by the test suite and not mentioned in the migration guide. src/Http/Http.php — specifically the wildcard() method and execute() dispatch path for wildcard routes; the telemetry block in run() also warrants a second look. Important Files Changed
Reviews (7): Last reviewed commit: "Make 'route' injection frame-local inste..." | 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>
| */ | ||
| public static function wildcard(): Route | ||
| { | ||
| self::$wildcardRoute = new Route('', ''); | ||
| $route = new Route('', ''); | ||
| Router::setWildcard($route); | ||
|
|
There was a problem hiding this comment.
Wildcard route's
getPath() now returns '' — undocumented behavior change
The old code called $route->path($path) to stamp the actual request URL onto the shared wildcard route before dispatch, so handlers that did ->inject('route') and read $route->getPath() would receive the real URL (e.g. /unknown/path). That mutation was coroutine-unsafe and is correctly removed here, but the replacement leaves getPath() returning '' for every wildcard match. Any downstream handler that used $route->getPath() to discover the wildcard-matched URL will silently receive '' instead. The migration guide does not mention this change; consider noting that handlers should switch to ->inject('request') and read $request->getURI() instead.
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.\$http->context().\$this->routeand\$this->matchedPathproperties on Http are gone. The matched route is set in the per-request context ('route'key) for the->inject('route')API and for telemetry.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: re-runs the lookup every call, no\$freshflag, no context cache.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.
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— 101 tests, 237 assertions, 0 failures (only the docker-required e2e tests error, unrelated)🤖 Generated with Claude Code