Skip to content

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

Open
loks0n wants to merge 24 commits into
mainfrom
feat-safe-wildcards
Open

Make routing coroutine-safe and tighten the routing API#255
loks0n wants to merge 24 commits into
mainfrom
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.
  • 'route' injection is frame-local. No \$this->route property, no context['route'] slot. Each dispatch frame carries its own matched Route as a parameter through getArguments; nested execute() 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 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 and pure: returns ?RouteMatch, writes nothing.
  • 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. `execute()` now manages the dispatch frame for you; nested calls don't leak.

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) because Http::runInternal mutated 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.route as unset (rather than as an empty string) per OTel semantic conventions.

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 — 102 tests, 238 assertions, 0 failures (only the docker-required e2e tests error, unrelated)
  • Regression test: testSubrequestRestoresOuterRoute covers nested execute() not leaking the inner match
  • 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
8253 req/s 577879 0% 4.83 ms 11.02 ms

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR makes routing coroutine-safe by eliminating all in-place mutation of shared state during request dispatch: Route::setMatchedPath is removed, the wildcard route is moved into Router, and $this->route / context('route') are replaced with a frame-local $locals array inside getArguments. The new RouteMatch value object carries the matched route and already-resolved path params, and execute(Request, Response) becomes a fully self-contained, re-entrant dispatch primitive.

  • Router::match now returns ?RouteMatch instead of mutating the shared Route; both RouterTest and HttpTest are updated to unpack ?->route from the result.
  • execute(Request, Response) replaces execute(Route, Request, Response): it does its own match, handles HEAD/OPTIONS/404 internally, and passes the matched Route as a frame-local injection rather than writing to shared context — preventing sub-request dispatch from clobbering the outer request's route injection.
  • Wildcard registration moved from Http::$wildcardRoute to Router::$wildcard, so Router::match handles it as the final fallback in one unified code path; Router::reset() clears it along with the route table.

Confidence Score: 4/5

The 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

Filename Overview
src/Http/Http.php Core dispatch rewrite: removes $this->route and context('route') mutation in favour of frame-local route injection; execute() now does its own match; run() re-calls match() post-dispatch for telemetry. One minor null-safety inconsistency in the OPTIONS group-hooks branch.
src/Http/Router.php match() return type changed from ?Route to ?RouteMatch; wildcard slot moved here from Http; setMatchedPath mutation removed; reset() now clears the wildcard. Clean and correct.
src/Http/RouteMatch.php New final readonly value object carrying the matched Route and resolved path params. Straightforward and correctly immutable.
src/Http/Route.php Removes matchedPath mutation; renames getPathValues(Request) to resolveParams(string, string), taking a URL string directly. Clean simplification.
tests/HttpTest.php Tests updated to new API (execute(Request, Response), match() returns ?RouteMatch); adds testSubrequestRestoresOuterRoute and params-injection tests; removes now-deleted getRoute/setRoute test.
tests/RouterTest.php All assertions updated to unpack ?->route from the new RouteMatch return type. No logic changes.

Reviews (10): Last reviewed commit: "Rename frameLocals to locals" | 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
loks0n and others added 3 commits May 15, 2026 18:43
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>
loks0n and others added 3 commits May 15, 2026 18:54
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>
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