Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion src/Http/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ abstract class Adapter
abstract public function onStart(callable $callback): void;
abstract public function onRequest(callable $callback): void;
abstract public function start(): void;
abstract public function getContainer(): Container;

/**
* Container for the current execution context: the per-request
* container inside a request (coroutine-local under Swoole, with
* parent-chain fallback to global), the global container otherwise.
*/
abstract public function getContext(): Container;
}
2 changes: 1 addition & 1 deletion src/Http/Adapter/FPM/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function onStart(callable $callback): void
\call_user_func($callback, $this);
}

public function getContainer(): Container
public function getContext(): Container
{
return $this->container;
}
Expand Down
14 changes: 7 additions & 7 deletions src/Http/Adapter/Swoole/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
class Server extends Adapter
{
protected SwooleServer $server;
protected const string REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container';
protected const string CONTEXT_KEY = '__utopia_http_context';
protected Container $container;

/**
Expand All @@ -28,20 +28,20 @@ public function __construct(string $host, ?string $port = null, array $settings
public function onRequest(callable $callback): void
{
$this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) {
$requestContainer = new Container($this->container);
$requestContainer->set('swooleRequest', fn() => $request);
$requestContainer->set('swooleResponse', fn() => $response);
$context = new Container($this->container);
$context->set('swooleRequest', fn() => $request);
$context->set('swooleResponse', fn() => $response);

Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer;
Coroutine::getContext()[self::CONTEXT_KEY] = $context;

\call_user_func($callback, new Request($request), new Response($response));
});
}

public function getContainer(): Container
public function getContext(): Container
{
if (Coroutine::getCid() !== -1) {
return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container;
return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container;
}

return $this->container;
Expand Down
16 changes: 8 additions & 8 deletions src/Http/Adapter/SwooleCoroutine/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class Server extends Adapter
{
protected const string REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container';
protected const string CONTEXT_KEY = '__utopia_http_context';

protected SwooleServer $server;
protected Container $container;
Expand All @@ -36,23 +36,23 @@ public function __construct(
public function onRequest(callable $callback): void
{
$this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) {
$requestContainer = new Container($this->container);
$requestContainer->set('swooleRequest', fn() => $request);
$requestContainer->set('swooleResponse', fn() => $response);
$context = new Container($this->container);
$context->set('swooleRequest', fn() => $request);
$context->set('swooleResponse', fn() => $response);

Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer;
Coroutine::getContext()[self::CONTEXT_KEY] = $context;

try {
\call_user_func($callback, new Request($request), new Response($response));
} finally {
unset(Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY]);
unset(Coroutine::getContext()[self::CONTEXT_KEY]);
}
});
}

public function getContainer(): Container
public function getContext(): Container
{
return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container;
return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container;
}

public function getServer(): SwooleServer
Expand Down
103 changes: 47 additions & 56 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 @@ -105,13 +103,6 @@ class Http
*/
protected static array $requestHooks = [];

/**
* Route
*
* Memory cached result for chosen route
*/
protected ?Route $route = null;

/**
* Wildcard route
* If set, this get's executed if no other route is matched
Expand Down Expand Up @@ -145,7 +136,8 @@ public function __construct(Adapter $server, string $timezone)
date_default_timezone_set($timezone);
$this->files = new Files();
$this->server = $server;
$this->container = $server->getContainer();
// Captures the global container; assumes Http is constructed at boot, not inside a request.
$this->container = $server->getContext();
$this->setTelemetry(new NoTelemetry());
}

Expand Down Expand Up @@ -372,7 +364,7 @@ public static function setAllowOverride(bool $value): void
public function getResource(string $name): mixed
{
try {
return $this->server->getContainer()->get($name);
return $this->server->getContext()->get($name);
} catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) {
// Normalize DI container errors to the Http layer's "resource" terminology.
$message = str_replace('dependency', 'resource', $e->getMessage());
Expand Down Expand Up @@ -415,13 +407,14 @@ public function setResource(string $name, callable $callback, array $injections
}

/**
* Set a request-scoped resource on the current request's container.
* Register a per-request value on the context container.
* Counterpart to setResource() for global singletons.
*
* @param list<string> $injections
*/
protected function setRequestResource(string $name, callable $callback, array $injections = []): void
public function setContext(string $name, callable $callback, array $injections = []): void
{
$this->server->getContainer()->set($name, $callback, $injections);
$this->server->getContext()->set($name, $callback, $injections);
}

/**
Expand Down Expand Up @@ -460,24 +453,6 @@ public static function getRoutes(): array
return Router::getRoutes();
}

/**
* Get the current route
*/
public function getRoute(): ?Route
{
return $this->route ?? null;
}

/**
* Set the current route
*/
public function setRoute(Route $route): self
{
$this->route = $route;

return $this;
}

/**
* Add Route
*
Expand Down Expand Up @@ -590,18 +565,24 @@ public function start(): void
*/
public function match(Request $request, bool $fresh = true): ?Route
{
if (null !== $this->route && !$fresh) {
return $this->route;
$context = $this->server->getContext();

if (!$fresh && $context->has('match')) {
$cached = $context->get('match');
if ($cached instanceof RouteMatch) {
return $cached->route;
}
}

$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);
$match = Router::match($method, $url);
$context->set('match', fn() => $match);

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

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

$preparedPath = Router::preparePath($route->getMatchedPath());
$context = $this->server->getContext();
$match = $context->has('match') ? $context->get('match') : null;
if (!$match instanceof RouteMatch || $match->route !== $route) {
// execute() called directly without a prior match().
$match = new RouteMatch($route, '');
$context->set('match', fn() => $match);
}
$preparedPath = Router::preparePath($match->path);
$pathValues = $route->getPathValues($request, $preparedPath[0]);

try {
Expand All @@ -635,7 +623,7 @@ public function execute(Route $route, Request $request, Response $response): sta
}

if (!$response->isSent()) {
$arguments = $this->getArguments($route, $pathValues, $request->getParams());
$arguments = $this->getArguments($route, $pathValues, $request->getParams(), $match->arguments);
\call_user_func_array($route->getAction(), $arguments);
}

Expand All @@ -657,7 +645,7 @@ public function execute(Route $route, Request $request, Response $response): sta
}
}
} catch (\Throwable $e) {
$this->setRequestResource('error', fn() => $e, []);
$this->setContext('error', fn() => $e, []);

foreach ($groups as $group) {
foreach (self::$errors as $error) { // Group error hooks
Expand Down Expand Up @@ -688,17 +676,17 @@ public function execute(Route $route, Request $request, Response $response): sta
}

/**
* Get Arguments
*
* @param array<string, mixed> $values
* @param array<string, mixed> $requestParams
* @param array<string, mixed> $resolved
* @param-out array<string, mixed> $resolved
* @return array<int, mixed>
* @throws Exception
*/
protected function getArguments(Hook $hook, array $values, array $requestParams): array
protected function getArguments(Hook $hook, array $values, array $requestParams, array &$resolved = []): array
{
$arguments = [];
foreach ($hook->getParams() as $key => $param) { // Get value from route or request object
foreach ($hook->getParams() as $key => $param) {
$existsInRequest = \array_key_exists($key, $requestParams);
$existsInValues = \array_key_exists($key, $values);
$paramExists = $existsInRequest || $existsInValues;
Expand All @@ -709,6 +697,8 @@ protected function getArguments(Hook $hook, array $values, array $requestParams)
}
$value = $existsInValues ? $values[$key] : $arg;

$resolved[(string) $key] = $value;

if (!$param['skipValidation']) {
if (!$paramExists && !$param['optional']) {
throw new Exception('Param "' . $key . '" is not optional.', 400);
Expand All @@ -719,7 +709,6 @@ protected function getArguments(Hook $hook, array $values, array $requestParams)
}
}

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

Expand All @@ -744,10 +733,12 @@ public function run(Request $request, Response $response): static
$result = $this->runInternal($request, $response);

$requestDuration = microtime(true) - $start;
$context = $this->server->getContext();
$match = $context->has('match') ? $context->get('match') : null;
$attributes = [
'url.scheme' => $request->getProtocol(),
'http.request.method' => $request->getMethod(),
'http.route' => $this->route?->getPath(),
'http.route' => $match instanceof RouteMatch ? $match->route->getPath() : null,
'http.response.status_code' => $response->getStatusCode(),
];
$this->requestDuration->record($requestDuration, $attributes);
Expand Down Expand Up @@ -777,16 +768,17 @@ private function runInternal(Request $request, Response $response): static
$response->setCompressionSupported($this->compressionSupported);
}

$this->setRequestResource('request', fn() => $request);
$this->setRequestResource('response', fn() => $response);
$this->setContext('request', fn() => $request);
$this->setContext('response', fn() => $response);
$this->setContext('match', fn() => null);

try {
foreach (self::$requestHooks as $hook) {
$arguments = $this->getArguments($hook, [], []);
\call_user_func_array($hook->getAction(), $arguments);
}
} catch (\Exception $e) {
$this->setRequestResource('error', fn() => $e, []);
$this->setContext('error', fn() => $e, []);

foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
Expand Down Expand Up @@ -816,8 +808,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();
Expand All @@ -844,7 +834,7 @@ private function runInternal(Request $request, Response $response): static
foreach (self::$errors as $error) { // Global error hooks
/** @var Hook $error */
if (\in_array('*', $error->getGroups())) {
$this->setRequestResource('error', fn() => $e, []);
$this->setContext('error', fn() => $e, []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
Expand All @@ -854,13 +844,14 @@ private function runInternal(Request $request, Response $response): static
}

if (null === $route && null !== self::$wildcardRoute) {
$route = self::$wildcardRoute;
$this->route = $route;
// Clone before stamping $path so concurrent coroutines don't fight over the singleton.
$route = clone self::$wildcardRoute;
$path = parse_url($request->getURI(), PHP_URL_PATH);
$path = \is_string($path) ? ($path === '' ? '/' : $path) : '/';
$route->path($path);

$this->setRequestResource('route', fn() => $route, []);
$match = new RouteMatch($route, '');
$this->setContext('match', fn() => $match);
}
if (null !== $route) {
return $this->execute($route, $request, $response);
Expand All @@ -884,15 +875,15 @@ private function runInternal(Request $request, Response $response): static
} catch (\Throwable $e) {
foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
$this->setRequestResource('error', fn() => $e, []);
$this->setContext('error', fn() => $e, []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
}
} else {
foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
$this->setRequestResource('error', fn() => new Exception('Not Found', 404), []);
$this->setContext('error', fn() => new Exception('Not Found', 404), []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
Expand Down
Loading
Loading