Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
20a944f
Fix: Eliminate shared-state races on the Http/Route/Router singletons
loks0n Apr 27, 2026
61bbb78
Chore: Mark dead Route::matchedPath methods and unused Http::$request…
loks0n Apr 27, 2026
b226bf5
Remove dead Route::matchedPath methods and unused Http::$requestConta…
loks0n Apr 28, 2026
6a86d7e
Use plain 'route'/'matchedPath' keys in the request container
loks0n Apr 28, 2026
042ed2e
Make Http::setRoute private
loks0n Apr 28, 2026
23c23c4
Rename request-scoped helpers to use 'context' terminology
loks0n Apr 28, 2026
694d807
Remove Http::getRoute / setRoute / get-set MatchedPath
loks0n Apr 28, 2026
2da69df
Format: single-quote 'route' in HttpTest
loks0n Apr 28, 2026
0707b7c
Split Adapter::getContainer() into getContainer + getContext
loks0n Apr 28, 2026
5f86d40
Inject route's resolved arguments into shutdown/error hooks
loks0n Apr 28, 2026
1ca856c
Bundle route + matchedPath + arguments into immutable RouteMatch
loks0n Apr 28, 2026
4117939
Rename RouteMatch::\$matchedPath to RouteMatch::\$path
loks0n Apr 28, 2026
e6ffc47
Drop RouteMatch::withArguments(), use the constructor directly
loks0n Apr 28, 2026
1200c1a
Seed context 'match' to null at the top of runInternal
loks0n Apr 28, 2026
a2c5636
Seed RouteMatch::\$arguments with path values at match-time
loks0n Apr 28, 2026
6340582
Make RouteMatch::\$arguments mutable, drop the replace-on-update dance
loks0n Apr 28, 2026
2e2c19a
Revert: path-value seeding into RouteMatch::\$arguments at match-time
loks0n Apr 28, 2026
e26b1a9
Collapse Adapter API to a single getContext()
loks0n Apr 28, 2026
536f945
Fix RouteMatch docblock: \$arguments is single-write, not two-pass
loks0n Apr 28, 2026
a3ece56
Trim verbose comments and docblocks
loks0n Apr 28, 2026
df3b31c
Populate \$match->arguments incrementally so error hooks see partials
loks0n Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions src/Http/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ class Http

protected Container $container;

protected ?Container $requestContainer = null;

/**
* Current running mode
*/
Expand Down Expand Up @@ -106,11 +104,14 @@ class Http
protected static array $requestHooks = [];

/**
* Route
*
* Memory cached result for chosen route
* Per-request container keys for the currently matched route and
* the prepared-path string it was matched under. Storing these on
* the per-request container (which is coroutine-local in the Swoole
* adapters) keeps concurrent requests from clobbering each other's
* routing state on the shared Http singleton.
*/
protected ?Route $route = null;
private const string ROUTE_CONTEXT_KEY = '__utopia_http_route';
private const string MATCHED_PATH_CONTEXT_KEY = '__utopia_http_matched_path';

/**
* Wildcard route
Expand Down Expand Up @@ -465,19 +466,38 @@ public static function getRoutes(): array
*/
public function getRoute(): ?Route
{
return $this->route ?? null;
$container = $this->server->getContainer();

return $container->has(self::ROUTE_CONTEXT_KEY) ? $container->get(self::ROUTE_CONTEXT_KEY) : null;
}

/**
* Set the current route
*/
public function setRoute(Route $route): self
public function setRoute(?Route $route): self
{
$this->route = $route;
$this->server->getContainer()->set(self::ROUTE_CONTEXT_KEY, fn() => $route);

return $this;
}

/**
* Read the prepared-path string the current request matched under.
* Falls back to '' so callers (e.g. tests invoking execute() directly)
* keep getting the legacy "first registered path-params" behavior.
*/
private function getMatchedPath(): string
{
$container = $this->server->getContainer();

return $container->has(self::MATCHED_PATH_CONTEXT_KEY) ? $container->get(self::MATCHED_PATH_CONTEXT_KEY) : '';
}

private function setMatchedPath(string $path): void
{
$this->server->getContainer()->set(self::MATCHED_PATH_CONTEXT_KEY, fn() => $path);
}

/**
* Add Route
*
Expand Down Expand Up @@ -590,18 +610,30 @@ public function start(): void
*/
public function match(Request $request, bool $fresh = true): ?Route
{
if (null !== $this->route && !$fresh) {
return $this->route;
if (!$fresh) {
$cached = $this->getRoute();
if (null !== $cached) {
return $cached;
}
}

$url = parse_url($request->getURI(), PHP_URL_PATH);
$url = \is_string($url) ? ($url === '' ? '/' : $url) : '/';
$method = $request->getMethod();
$method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method;

$this->route = Router::match($method, $url);
$matched = Router::match($method, $url);
if (null === $matched) {
$this->setRoute(null);
$this->setMatchedPath('');
return null;
}

[$route, $matchedPath] = $matched;
$this->setRoute($route);
$this->setMatchedPath($matchedPath);

return $this->route;
return $route;
}

/**
Expand All @@ -612,7 +644,7 @@ public function execute(Route $route, Request $request, Response $response): sta
$arguments = [];
$groups = $route->getGroups();

$preparedPath = Router::preparePath($route->getMatchedPath());
$preparedPath = Router::preparePath($this->getMatchedPath());
$pathValues = $route->getPathValues($request, $preparedPath[0]);

try {
Expand Down Expand Up @@ -719,7 +751,6 @@ protected function getArguments(Hook $hook, array $values, array $requestParams)
}
}

$hook->setParamValue($key, $value);
$arguments[$param['order']] = $value;
}

Expand Down Expand Up @@ -747,7 +778,7 @@ public function run(Request $request, Response $response): static
$attributes = [
'url.scheme' => $request->getProtocol(),
'http.request.method' => $request->getMethod(),
'http.route' => $this->route?->getPath(),
'http.route' => $this->getRoute()?->getPath(),
'http.response.status_code' => $response->getStatusCode(),
];
$this->requestDuration->record($requestDuration, $attributes);
Expand Down Expand Up @@ -854,12 +885,14 @@ private function runInternal(Request $request, Response $response): static
}

if (null === $route && null !== self::$wildcardRoute) {
$route = self::$wildcardRoute;
$this->route = $route;
// Clone so we can stamp the request URI onto $path without
// mutating the singleton shared across concurrent coroutines.
$route = clone self::$wildcardRoute;
$path = parse_url($request->getURI(), PHP_URL_PATH);
$path = \is_string($path) ? ($path === '' ? '/' : $path) : '/';
$route->path($path);

$this->setRoute($route);
$this->setRequestResource('route', fn() => $route, []);
}
if (null !== $route) {
Expand Down
13 changes: 0 additions & 13 deletions src/Http/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ class Route extends Hook
*/
protected int $order;

protected string $matchedPath = '';

public function __construct(string $method, string $path)
{
parent::__construct();
Expand All @@ -48,17 +46,6 @@ public function __construct(string $method, string $path)
$this->order = ++self::$counter;
}
Comment thread
loks0n marked this conversation as resolved.

public function setMatchedPath(string $path): self
{
$this->matchedPath = $path;
return $this;
}

public function getMatchedPath(): string
{
return $this->matchedPath;
}

/**
* Get Route Order ID
*/
Expand Down
20 changes: 10 additions & 10 deletions src/Http/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,14 @@ public static function addRouteAlias(string $path, Route $route): void

/**
* Match route against the method and path.
*
* Returns the matched Route together with the prepared-path key it was
* found under, so callers can resolve path params without mutating the
* shared Route singleton.
*
* @return array{0: Route, 1: string}|null
*/
public static function match(string $method, string $path): ?Route
public static function match(string $method, string $path): ?array
{
if (!\array_key_exists($method, self::$routes)) {
return null;
Expand All @@ -129,9 +135,7 @@ public static function match(string $method, string $path): ?Route
);

if (\array_key_exists($match, self::$routes[$method])) {
$route = self::$routes[$method][$match];
$route->setMatchedPath($match);
return $route;
return [self::$routes[$method][$match], $match];
}
}

Expand All @@ -140,9 +144,7 @@ public static function match(string $method, string $path): ?Route
*/
$match = self::WILDCARD_TOKEN;
if (\array_key_exists($match, self::$routes[$method])) {
$route = self::$routes[$method][$match];
$route->setMatchedPath($match);
return $route;
return [self::$routes[$method][$match], $match];
}

/**
Expand All @@ -152,9 +154,7 @@ public static function match(string $method, string $path): ?Route
$current = ($current ?? '') . "{$part}/";
$match = $current . self::WILDCARD_TOKEN;
if (\array_key_exists($match, self::$routes[$method])) {
$route = self::$routes[$method][$match];
$route->setMatchedPath($match);
return $route;
return [self::$routes[$method][$match], $match];
}
}

Expand Down
70 changes: 35 additions & 35 deletions tests/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public function testCanMatchUrl(): void
Router::addRoute($routeAbout);
Router::addRoute($routeAboutMe);

$this->assertEquals($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/'));
$this->assertEquals($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about'));
$this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me'));
$this->assertSame($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null);
$this->assertSame($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')[0] ?? null);
$this->assertSame($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')[0] ?? null);
}

public function testCanMatchUrlWithPlaceholder(): void
Expand All @@ -44,12 +44,12 @@ public function testCanMatchUrlWithPlaceholder(): void
Router::addRoute($routeBlogPostComments);
Router::addRoute($routeBlogPostCommentsSingle);

$this->assertEquals($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog'));
$this->assertEquals($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors'));
$this->assertEquals($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments'));
$this->assertEquals($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test'));
$this->assertEquals($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments'));
$this->assertEquals($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment'));
$this->assertSame($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')[0] ?? null);
$this->assertSame($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')[0] ?? null);
$this->assertSame($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')[0] ?? null);
$this->assertSame($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')[0] ?? null);
$this->assertSame($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')[0] ?? null);
$this->assertSame($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')[0] ?? null);
}

public function testCanMatchUrlWithWildcard(): void
Expand All @@ -62,11 +62,11 @@ public function testCanMatchUrlWithWildcard(): void
Router::addRoute($routeAbout);
Router::addRoute($routeAboutWildcard);

$this->assertEquals($routeIndex, Router::match('GET', '/'));
$this->assertEquals($routeAbout, Router::match('GET', '/about'));
$this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me'));
$this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/you'));
$this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me/myself/i'));
$this->assertSame($routeIndex, Router::match('GET', '/')[0] ?? null);
$this->assertSame($routeAbout, Router::match('GET', '/about')[0] ?? null);
$this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me')[0] ?? null);
$this->assertSame($routeAboutWildcard, Router::match('GET', '/about/you')[0] ?? null);
$this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')[0] ?? null);
}

public function testCanMatchHttpMethod(): void
Expand All @@ -77,11 +77,11 @@ public function testCanMatchHttpMethod(): void
Router::addRoute($routeGET);
Router::addRoute($routePOST);

$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/'));
$this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/'));
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null);
$this->assertSame($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')[0] ?? null);

$this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/'));
$this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/'));
$this->assertNotSame($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')[0]);
$this->assertNotSame($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')[0]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

public function testCanMatchAlias(): void
Expand All @@ -93,9 +93,9 @@ public function testCanMatchAlias(): void

Router::addRoute($routeGET);

$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2'));
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null);
}

public function testCanMatchMultipleAliases(): void
Expand All @@ -108,10 +108,10 @@ public function testCanMatchMultipleAliases(): void

Router::addRoute($routeGET);

$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3'));
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')[0] ?? null);
}

public function testCanMatchMix(): void
Expand All @@ -127,22 +127,22 @@ public function testCanMatchMix(): void

Router::addRoute($routeGET);

$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum'));
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum'));
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')[0] ?? null);
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')[0] ?? null);
}

public function testCanMatchFilename(): void
{
$routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt');

Router::addRoute($routeGET);
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt'));
$this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')[0] ?? null);
}

public function testCannotFindUnknownRouteByPath(): void
Expand All @@ -156,7 +156,7 @@ public function testCannotFindUnknownRouteByMethod(): void

Router::addRoute($route);

$this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404'));
$this->assertSame($route, Router::match(Http::REQUEST_METHOD_GET, '/404')[0] ?? null);

$this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404'));
}
Expand Down
Loading