From 3d1278e5e9a85eedf26287e9a7fba70fade4cff0 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:48:40 +0100 Subject: [PATCH 1/5] Fix: store current route in per-request container The Http instance is shared across coroutines, but $this->route was a plain instance property mutated on every request. Concurrent coroutines could clobber each other's route, corrupting http.route telemetry, getRoute()/setRoute() reads from hooks, and the wildcard fallback path. Move the route into the per-request DI container (already used via setRequestResource('route', ...)) and drop the redundant $this->route property so each coroutine sees only its own match. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 7bdd100..c364c52 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -110,15 +110,6 @@ class Http */ protected static array $requestHooks = []; - /** - * Route - * - * Memory cached result for chosen route - * - * @var Route|null - */ - protected ?Route $route = null; - /** * Wildcard route * If set, this get's executed if no other route is matched @@ -530,7 +521,19 @@ public static function getRoutes(): array */ public function getRoute(): ?Route { - return $this->route ?? null; + $container = $this->server->getContainer(); + + if (!$container->has('route')) { + return null; + } + + try { + $route = $container->get('route'); + } catch (\Throwable) { + return null; + } + + return $route instanceof Route ? $route : null; } /** @@ -540,7 +543,7 @@ public function getRoute(): ?Route */ public function setRoute(Route $route): self { - $this->route = $route; + $this->setRequestResource('route', fn () => $route, []); return $this; } @@ -675,8 +678,12 @@ public function start() */ 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); @@ -684,9 +691,13 @@ public function match(Request $request, bool $fresh = true): ?Route $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD == $method) ? self::REQUEST_METHOD_GET : $method; - $this->route = Router::match($method, $url); + $route = Router::match($method, $url); - return $this->route; + if (null !== $route) { + $this->setRequestResource('route', fn () => $route, []); + } + + return $route; } /** @@ -837,7 +848,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); @@ -948,7 +959,6 @@ private function runInternal(Request $request, Response $response): static if (null === $route && null !== self::$wildcardRoute) { $route = self::$wildcardRoute; - $this->route = $route; $path = \parse_url($request->getURI(), PHP_URL_PATH); $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); From 12a1056001588987731d998163e02303ef417e63 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:53:31 +0100 Subject: [PATCH 2/5] Test: drop stale-cache assertion after fresh re-match DI 0.3.1's Container::set() overwrites the factory but does not invalidate the resolved-instance cache, so a second match(fresh: true) against the same request-scoped container leaves getRoute() returning the first match. In real request flow this never happens (each request gets a fresh container and route is set once), so just drop the assertion that exercised the old property-based mid-request override. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/HttpTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 2e00645..5045bb9 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -477,7 +477,6 @@ public function testCanMatchFreshRoute(): void // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); $this->assertEquals($route2, $matched); - $this->assertEquals($route2, $this->http->getRoute()); } catch (\Exception $e) { $this->fail($e->getMessage()); } From 441bfed8fcca6763d2e5737ada10c78fa9f9803b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:54:09 +0100 Subject: [PATCH 3/5] Fix: clone wildcard route so concurrent requests don't race on path() self::\$wildcardRoute is a static singleton. Calling \$route->path(\$path) mutates its in-place path state, which is shared across all coroutines even though the per-request container now isolates the route pointer. Two concurrent wildcard requests would overwrite each other's path before execute() reads getMatchedPath() for path-parameter extraction. Clone the wildcard route per request so each coroutine mutates its own copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index c364c52..70d6515 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -958,7 +958,7 @@ private function runInternal(Request $request, Response $response): static } if (null === $route && null !== self::$wildcardRoute) { - $route = self::$wildcardRoute; + $route = clone self::$wildcardRoute; $path = \parse_url($request->getURI(), PHP_URL_PATH); $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); From 8db8e56e5439c545d3f278f2da73239ddd9816f1 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:54:38 +0100 Subject: [PATCH 4/5] Refactor: consolidate route container write in match() match() already writes the matched route to the per-request container, and runInternal() redundantly wrote it again right after calling match. For the no-match case the duplicate write stored a null factory that getRoute() already handles by returning null when the key is missing. Drop the second write so match() is the single owner of the route resource; the wildcard branch still sets it directly since it produces its own cloned route. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 70d6515..3753c8d 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -918,8 +918,6 @@ private function runInternal(Request $request, Response $response): static $route = $this->match($request); $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->setRequestResource('route', fn () => $route, []); - if (self::REQUEST_METHOD_HEAD == $method) { $method = self::REQUEST_METHOD_GET; $response->disablePayload(); From d0c7a3acb2be60763d5b3b373600bd3bcefbb299 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:02:46 +0100 Subject: [PATCH 5/5] Bump utopia-php/di to ^0.3.2 for set() cache invalidation DI 0.3.1's Container::set() overwrote the factory but did not invalidate the resolved-instance cache, so a second setRequestResource('route', ...) within a request (or after any hook resolved 'route' via injection) silently returned the stale value. 0.3.2 clears the concrete cache on set(), which fixes the failing fresh-rematch assertion and makes the coroutine-safety fix robust against mid-request route updates. Restore the testCanMatchFreshRoute assertion that exercises this path. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- composer.lock | 14 +++++++------- tests/HttpTest.php | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 833b4cc..2ce655a 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "require": { "php": ">=8.2", "ext-swoole": "*", - "utopia-php/di": "0.3.*", + "utopia-php/di": "^0.3.2", "utopia-php/servers": "0.3.*", "utopia-php/compression": "0.1.*", "utopia-php/telemetry": "0.2.*", diff --git a/composer.lock b/composer.lock index 6932a58..7860826 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "836a13c7945c3bf4de3cd30991d29bf0", + "content-hash": "0a629fd72a7f6958b59d11324b2436d4", "packages": [ { "name": "brick/math", @@ -1915,16 +1915,16 @@ }, { "name": "utopia-php/di", - "version": "0.3.1", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/di.git", - "reference": "68873b7267842315d01d82a83b988bae525eab31" + "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/di/zipball/68873b7267842315d01d82a83b988bae525eab31", - "reference": "68873b7267842315d01d82a83b988bae525eab31", + "url": "https://api.github.com/repos/utopia-php/di/zipball/07025d721ed5d9be27932e8e640acf1467fc4b9d", + "reference": "07025d721ed5d9be27932e8e640acf1467fc4b9d", "shasum": "" }, "require": { @@ -1960,9 +1960,9 @@ ], "support": { "issues": "https://github.com/utopia-php/di/issues", - "source": "https://github.com/utopia-php/di/tree/0.3.1" + "source": "https://github.com/utopia-php/di/tree/0.3.2" }, - "time": "2026-03-13T05:47:23+00:00" + "time": "2026-03-21T07:42:10+00:00" }, { "name": "utopia-php/servers", diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 5045bb9..2e00645 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -477,6 +477,7 @@ public function testCanMatchFreshRoute(): void // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); $this->assertEquals($route2, $matched); + $this->assertEquals($route2, $this->http->getRoute()); } catch (\Exception $e) { $this->fail($e->getMessage()); }