Skip to content

Make routing coroutine-safe and tighten the routing API#255

Open
loks0n wants to merge 18 commits intomainfrom
feat-safe-wildcards
Open

Make routing coroutine-safe and tighten the routing API#255
loks0n wants to merge 18 commits intomainfrom
feat-safe-wildcards

Conversation

@loks0n
Copy link
Copy Markdown
Contributor

@loks0n loks0n commented May 5, 2026

Summary

Routing on this branch was unsafe under coroutines: Router::match wrote setMatchedPath onto the shared Route on every request, the wildcard branch in Http::runInternal called \$route->path() per request, and \$this->route lived 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::match returns ?RouteMatch. A final readonly value 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.
  • No more Route::matchedPath / setMatchedPath / getMatchedPath. Path-param resolution moved to Route::resolveParams(string \$url, string \$matchedTemplate), called once at match time by Router::match.
  • The wildcard catch-all flows through Router. Http::wildcard() now registers via Router::setWildcard(\$route). The special-case fallback branch in Http::runInternal is gone; Router::match consults the wildcard slot as a final fallback so wildcard requests follow the same dispatch path as any other route.
  • Per-request state lives in \$http->context(). \$this->route and \$this->matchedPath properties 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 to Http::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 \$fresh flag, no context cache.
  • Removed: Http::setRoute(), Http::getRoute(), Http::matchInternal(), the \$fresh parameter on match(), 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:

  • `$utopia->match($request, fresh: true)` → `$utopia->match($request)?->route`
  • `$utopia->execute($route, $request, $response)` → `$utopia->execute($request, $response)`
  • `$utopia->getRoute()` / `$utopia->setRoute($original)` save-restore — drop, or replace with direct context manipulation if telemetry accuracy on the outer request matters.

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::setParamValue mutates static hooks per request. Same bug class as the Route mutation we fixed. Hooks live in self::\$init / \$shutdown / \$errors etc., shared across coroutines. Worth a separate PR.
  • Static route registration is not enforced as boot-only. Router::\$routes, \$wildcard, Http::\$init etc. mutate via static methods. Fine at boot, race city if anyone calls Http::get(...) mid-request. Worth a class-doc note and possibly a frozen-after-start flag.

Test plan

  • vendor/bin/phpstan analyse clean
  • vendor/bin/phpunit --exclude-group e2e — 101 tests, 237 assertions, 0 failures (only the docker-required e2e tests error, unrelated)
  • Swoole e2e tests (require docker)
  • Sanity-check downstream GraphQL migration in Appwrite

🤖 Generated with Claude Code

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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 5, 2026

k6 benchmark

Throughput Requests Fail rate p50 p95
7878 req/s 551543 0% 4.79 ms 12.62 ms

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR makes the routing path coroutine-safe by eliminating all per-request mutations on shared objects: Route::setMatchedPath is gone, $this->route on Http is gone, and the wildcard route is no longer stamped with the actual request URL mid-flight. The new RouteMatch value object carries the matched route and resolved params immutably from lookup to dispatch, and ->inject('route') is now resolved directly in getArguments rather than through shared context.

  • Router::match returns ?RouteMatch instead of ?Route; Http::execute now takes (Request, Response) and does its own match internally, making it safe to call re-entrantly from handlers.
  • Route::resolveParams(string $url, string $matchedTemplate) replaces getPathValues(Request, string), decoupling param resolution from the Request object and incidentally fixing a latent bug where query-string content leaked into path-param values.
  • The wildcard catch-all is registered via Router::setWildcard and flows through the same Router::match path as all other routes, removing the special-case branch in runInternal.

Confidence Score: 3/5

Mostly 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

Filename Overview
src/Http/Http.php Core dispatch rewrite: removes shared mutable $route/$wildcardRoute state, introduces execute(Request, Response) as re-entrant primitive, and passes route directly through getArguments to make ->inject('route') frame-local. The wildcard route no longer has its path stamped to the actual URL (regression for handlers using $route->getPath()), and run() performs a redundant match() call for telemetry.
src/Http/Router.php Return type changed from ?Route to ?RouteMatch; wildcard slot moved here from Http; setMatchedPath mutation eliminated. Logic is clean and tests cover the key paths.
src/Http/RouteMatch.php New final readonly value object carrying a matched Route and its resolved path params — straightforward and correct.
src/Http/Route.php Removes matchedPath mutation and renames getPathValues(Request, string) to resolveParams(string, string) — decoupling from Request is an improvement and also fixes a latent bug where query-string content was included in path-param values.
tests/HttpTest.php Tests updated to use execute(Request, Response) and match()?->route; new testSubrequestRestoresOuterRoute validates re-entrant dispatch isolation. testWildcardRoute doesn't assert getPath() on the wildcard route, so the behavior regression there isn't caught.
tests/RouterTest.php Tests updated to unpack ?->route from the new ?RouteMatch return type; all existing match-path and match-method assertions are preserved.

Reviews (7): Last reviewed commit: "Make 'route' injection frame-local inste..." | Re-trigger Greptile

Comment thread src/Http/Router.php Outdated
loks0n and others added 5 commits May 5, 2026 16:22
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>
Comment thread src/Http/Http.php Outdated
- 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>
Comment thread src/Http/Http.php Outdated
Comment thread tests/HttpTest.php Outdated
loks0n and others added 6 commits May 5, 2026 16:54
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>
@loks0n loks0n changed the title Make routing coroutine-safe by removing Route mutations Make routing coroutine-safe and tighten the routing API May 5, 2026
loks0n and others added 3 commits May 5, 2026 17:16
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>
Comment thread src/Http/Http.php Outdated
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>
Comment thread src/Http/Http.php Outdated
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>
Comment thread src/Http/Http.php
Comment on lines 229 to 234
*/
public static function wildcard(): Route
{
self::$wildcardRoute = new Route('', '');
$route = new Route('', '');
Router::setWildcard($route);

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 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.

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