-
Notifications
You must be signed in to change notification settings - Fork 35
Refactor: Coroutine-safe request dispatch #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
be1cd57
42c7d02
4f5d41a
47f2349
07087af
c6770ad
1493a84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Http; | ||
|
|
||
| /** | ||
| * Per-request request dispatcher. | ||
| * | ||
| * Owns the mutable state for a single request/response cycle so the | ||
| * {@see Http} singleton carries only bootstrap configuration. This keeps | ||
| * the library safe under concurrent request handling (Swoole coroutines). | ||
| * | ||
| * One Dispatcher instance is constructed per request by {@see Http::run()}. | ||
| * @internal | ||
| */ | ||
| final class Dispatcher | ||
| { | ||
| private ?RouteMatch $match = null; | ||
|
|
||
| public function __construct( | ||
| private readonly Http $http, | ||
| private readonly Request $request, | ||
| private readonly Response $response, | ||
| ) {} | ||
|
|
||
| public function matchedRoute(): ?Route | ||
| { | ||
| return $this->match?->route; | ||
| } | ||
|
|
||
| public function matchedRouteMatch(): ?RouteMatch | ||
| { | ||
| return $this->match; | ||
| } | ||
|
|
||
| public function handle(): void | ||
| { | ||
| if ($this->http->isCompressionEnabled()) { | ||
| $this->response->setAcceptEncoding($this->request->getHeader('accept-encoding', '')); | ||
| $this->response->setCompressionMinSize($this->http->getCompressionMinSize()); | ||
| $this->response->setCompressionSupported($this->http->getCompressionSupported()); | ||
| } | ||
|
|
||
| $this->http->setRequestResource('request', fn() => $this->request); | ||
| $this->http->setRequestResource('response', fn() => $this->response); | ||
|
|
||
| try { | ||
| foreach (Hooks::$request as $hook) { | ||
| $arguments = $this->http->getArguments($hook, [], []); | ||
| \call_user_func_array($hook->getAction(), $arguments); | ||
| } | ||
| } catch (\Exception $e) { | ||
| $this->http->setRequestResource('error', fn() => $e); | ||
|
|
||
| foreach (Hooks::$errors as $error) { | ||
| if (\in_array('*', $error->getGroups())) { | ||
| try { | ||
| $arguments = $this->http->getArguments($error, [], []); | ||
| \call_user_func_array($error->getAction(), $arguments); | ||
| } catch (\Throwable $e) { | ||
| throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if ($this->http->isFileLoaded($this->request->getURI())) { | ||
| $time = (60 * 60 * 24 * 365 * 2); | ||
|
|
||
| $this->response | ||
| ->setContentType($this->http->getFileMimeType($this->request->getURI())) | ||
| ->addHeader('Cache-Control', 'public, max-age=' . $time) | ||
| ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') | ||
| ->send($this->http->getFileContents($this->request->getURI())); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $method = $this->request->getMethod(); | ||
| $this->match = Router::matchRequest($this->request); | ||
|
|
||
| $this->http->setRequestResource('route', fn() => $this->match?->route); | ||
| $this->http->setRequestResource('routeMatch', fn() => $this->match); | ||
|
|
||
| $groups = $this->match?->route->getGroups() ?? []; | ||
|
|
||
| if (Http::REQUEST_METHOD_HEAD === $method) { | ||
| $method = Http::REQUEST_METHOD_GET; | ||
| $this->response->disablePayload(); | ||
| } | ||
|
Comment on lines
+66
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm whether Response::send honors disablePayload via an internal flag
# and whether any existing test covers HEAD-against-static-file to document
# pre-existing vs regression behavior.
rg -nP --type=php -C3 '\bdisablePayload\s*\('
rg -nP --type=php -C3 'function\s+send\s*\(' -g 'src/Http/Response*.php'
rg -nP --type=php -C3 'REQUEST_METHOD_HEAD|isFileLoaded' -g 'tests/**'Repository: utopia-php/http Length of output: 1850 🏁 Script executed: sed -n '537,620p' src/Http/Response.phpRepository: utopia-php/http Length of output: 2812 🏁 Script executed: sed -n '640,680p' src/Http/Response.phpRepository: utopia-php/http Length of output: 1057 🏁 Script executed: sed -n '60,95p' src/Http/Http/Dispatcher.phpRepository: utopia-php/http Length of output: 131 🏁 Script executed: # Search for any test that uses HEAD method or tests static files
rg -i "head.*static|static.*head" tests/ --type=php
rg "REQUEST_METHOD_HEAD" tests/ --type=php -B2 -A2
rg "loadFiles|isFileLoaded" tests/ --type=php -B2 -A2Repository: utopia-php/http Length of output: 41 🏁 Script executed: fd -type f "Dispatcher.php" | head -5Repository: utopia-php/http Length of output: 230 🏁 Script executed: # Find correct path and read the file
DISP_PATH=$(fd -type f "Dispatcher.php" | head -1)
sed -n '60,95p' "$DISP_PATH"Repository: utopia-php/http Length of output: 274 🏁 Script executed: # Search for tests that exercise Dispatcher with static files or HEAD
rg "Dispatcher|loadFiles" tests/ --type=php -l | head -10Repository: utopia-php/http Length of output: 84 🏁 Script executed: fd "Dispatcher.php" --type f | grep -v testRepository: utopia-php/http Length of output: 83 🏁 Script executed: # Once we find it, read the dispatcher code
DISP=$(fd "Dispatcher.php" --type f | grep -v test)
sed -n '60,95p' "$DISP"Repository: utopia-php/http Length of output: 1471 🏁 Script executed: # Check DispatcherTest.php for HEAD or static file tests
head -100 tests/DispatcherTest.phpRepository: utopia-php/http Length of output: 3219 🏁 Script executed: # Search for HEAD method tests in DispatcherTest
rg -A10 -B5 "HEAD|head|loadFile" tests/DispatcherTest.phpRepository: utopia-php/http Length of output: 739 🏁 Script executed: # Check if Response::send has any other HEAD handling besides disablePayload
rg -C5 "REQUEST_METHOD_HEAD" src/Http/Response.phpRepository: utopia-php/http Length of output: 41 🏁 Script executed: # Double-check: does Response have any HEAD-specific logic in send/write methods?
rg "HEAD|head" src/Http/Response.php | grep -i "method\|header"Repository: utopia-php/http Length of output: 2886 HEAD requests to preloaded static files still stream the full body.
Normalize the method before the file-serving branch: 🔧 Proposed fix+ $method = $this->request->getMethod();
+ if (Http::REQUEST_METHOD_HEAD === $method) {
+ $this->response->disablePayload();
+ }
+
if ($this->http->isFileLoaded($this->request->getURI())) {
$time = (60 * 60 * 24 * 365 * 2);
$this->response
->setContentType($this->http->getFileMimeType($this->request->getURI()))
->addHeader('Cache-Control', 'public, max-age=' . $time)
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
->send($this->http->getFileContents($this->request->getURI()));
return;
}
- $method = $this->request->getMethod();
$this->match = Router::matchRequest($this->request);
$this->http->setRequestResource('route', fn() => $this->match?->route);
$this->http->setRequestResource('routeMatch', fn() => $this->match);
$groups = $this->match?->route->getGroups() ?? [];
if (Http::REQUEST_METHOD_HEAD === $method) {
$method = Http::REQUEST_METHOD_GET;
- $this->response->disablePayload();
}🤖 Prompt for AI Agents |
||
|
|
||
| if (Http::REQUEST_METHOD_OPTIONS === $method) { | ||
| try { | ||
| foreach ($groups as $group) { | ||
| foreach (Hooks::$options as $option) { | ||
| if (\in_array($group, $option->getGroups())) { | ||
| \call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| foreach (Hooks::$options as $option) { | ||
| if (\in_array('*', $option->getGroups())) { | ||
| \call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams())); | ||
| } | ||
| } | ||
| } catch (\Throwable $e) { | ||
| foreach (Hooks::$errors as $error) { | ||
| if (\in_array('*', $error->getGroups())) { | ||
| $this->http->setRequestResource('error', fn() => $e); | ||
| \call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| if ($this->match !== null) { | ||
| $this->execute($this->match); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| foreach (Hooks::$errors as $error) { | ||
| if (\in_array('*', $error->getGroups())) { | ||
| $this->http->setRequestResource('error', fn() => new Exception('Not Found', 404)); | ||
| \call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public function execute(RouteMatch $match): void | ||
| { | ||
| $route = $match->route; | ||
| $groups = $route->getGroups(); | ||
| $pathValues = $route->getPathValues($this->request, $match->preparedPath); | ||
|
|
||
| // Request params are re-read at each call site: init/request hooks | ||
| // may mutate the request (e.g. applying filters), and later hooks + | ||
| // the route action must see the updated view. Hoisting this into a | ||
| // local would cache stale params across the lifecycle. | ||
|
|
||
| try { | ||
| if ($route->getHook()) { | ||
| foreach (Hooks::$init as $hook) { | ||
| if (\in_array('*', $hook->getGroups())) { | ||
| \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| foreach ($groups as $group) { | ||
| foreach (Hooks::$init as $hook) { | ||
| if (\in_array($group, $hook->getGroups())) { | ||
| \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!$this->response->isSent()) { | ||
| \call_user_func_array($route->getAction(), $this->http->getArguments($route, $pathValues, $this->request->getParams())); | ||
| } | ||
|
|
||
| foreach ($groups as $group) { | ||
| foreach (Hooks::$shutdown as $hook) { | ||
| if (\in_array($group, $hook->getGroups())) { | ||
| \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if ($route->getHook()) { | ||
| foreach (Hooks::$shutdown as $hook) { | ||
| if (\in_array('*', $hook->getGroups())) { | ||
| \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); | ||
| } | ||
| } | ||
| } | ||
| } catch (\Throwable $e) { | ||
| $this->http->setRequestResource('error', fn() => $e); | ||
|
|
||
| foreach ($groups as $group) { | ||
| foreach (Hooks::$errors as $error) { | ||
| if (\in_array($group, $error->getGroups())) { | ||
| try { | ||
| \call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams())); | ||
| } catch (\Throwable $e) { | ||
| throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| foreach (Hooks::$errors as $error) { | ||
| if (\in_array('*', $error->getGroups())) { | ||
| try { | ||
| \call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams())); | ||
| } catch (\Throwable $e) { | ||
| throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Utopia\Http; | ||
|
|
||
| use Utopia\Servers\Hook; | ||
|
|
||
| /** | ||
| * Process-global hook registry. | ||
| * | ||
| * Owns the lifecycle-hook arrays (init/shutdown/options/error/start/request) | ||
| * that used to live on {@see Http} as protected statics. Keeping them here | ||
| * means {@see Dispatcher} can read the registries through a dedicated | ||
| * primitive instead of forcing {@see Http} to expose six `getXxxHooks()` | ||
| * accessors purely for internal consumption. | ||
| * | ||
| * Hooks are populated at bootstrap and must not be mutated after the | ||
| * server starts accepting requests; registration APIs are public static | ||
| * methods, reads are public static arrays. | ||
| */ | ||
| final class Hooks | ||
| { | ||
| /** @var Hook[] */ | ||
| public static array $init = []; | ||
|
|
||
| /** @var Hook[] */ | ||
| public static array $shutdown = []; | ||
|
|
||
| /** @var Hook[] */ | ||
| public static array $options = []; | ||
|
|
||
| /** @var Hook[] */ | ||
| public static array $errors = []; | ||
|
|
||
| /** @var Hook[] */ | ||
| public static array $start = []; | ||
|
|
||
| /** @var Hook[] */ | ||
| public static array $request = []; | ||
|
|
||
| /** | ||
| * Register a callback that runs before the matched route action. | ||
| */ | ||
| public static function init(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| $hook->groups(['*']); | ||
| self::$init[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Register a callback that runs after the matched route action. | ||
| */ | ||
| public static function shutdown(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| $hook->groups(['*']); | ||
| self::$shutdown[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Register a callback for OPTIONS method requests. | ||
| */ | ||
| public static function options(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| $hook->groups(['*']); | ||
| self::$options[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Register an error callback. | ||
| */ | ||
| public static function error(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| $hook->groups(['*']); | ||
| self::$errors[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Register a callback that runs once when the server starts. | ||
| */ | ||
| public static function onStart(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| self::$start[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Register a callback that runs at the top of every request, before | ||
| * route matching. | ||
| */ | ||
| public static function onRequest(): Hook | ||
| { | ||
| $hook = new Hook(); | ||
| self::$request[] = $hook; | ||
|
|
||
| return $hook; | ||
| } | ||
|
|
||
| /** | ||
| * Clear every registered hook. Intended for test isolation. | ||
| */ | ||
| public static function reset(): void | ||
| { | ||
| self::$init = []; | ||
| self::$shutdown = []; | ||
| self::$options = []; | ||
| self::$errors = []; | ||
| self::$start = []; | ||
| self::$request = []; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fall-through after onRequest error handling can double-send / dispatch a route.
After catching an onRequest hook error and running wildcard error handlers (which almost always write a response), control continues into static-file serving (Lines 66–76) and then into route matching +
execute(). That means the matched route action and its init/shutdown hooks can still run after the request was already rejected, and->send()can be invoked twice. Consider returning early from thecatchblock, or guarding the rest ofhandle()withif ($this->response->isSent()) { return; }— similar to the isSent() check already used at Line 160 insideexecute().🔧 Proposed fix
} catch (\Exception $e) { $this->http->setRequestResource('error', fn() => $e); foreach (Hooks::$errors as $error) { if (\in_array('*', $error->getGroups())) { try { $arguments = $this->http->getArguments($error, [], []); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); } } } + + return; }🤖 Prompt for AI Agents