From fe02964ebda7ac81301e5a29eb3ac18cd958ecd0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 19:01:20 +0530 Subject: [PATCH] fix: finalize graphql coroutine response isolation --- app/http.php | 3 +- src/Appwrite/GraphQL/Resolvers.php | 205 +++++++++++++++++------------ src/Appwrite/Promises/Promise.php | 38 ++++-- src/Appwrite/Utopia/Response.php | 12 ++ 4 files changed, 161 insertions(+), 97 deletions(-) diff --git a/app/http.php b/app/http.php index 67da67376d..afcc2d2d0f 100644 --- a/app/http.php +++ b/app/http.php @@ -72,8 +72,6 @@ $swooleAdapter = new Server( container: $container, ); -$container->set('container', fn () => fn () => $swooleAdapter->getContainer()); - $http = $swooleAdapter->getServer(); /** @@ -533,6 +531,7 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files } $requestContainer = $swooleAdapter->getContainer(); + $requestContainer->set('container', fn () => $requestContainer); $requestContainer->set('request', fn () => $request); $requestContainer->set('response', fn () => $response); diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index a321c196ce..83342bc31d 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -71,10 +71,15 @@ class Resolvers */ private static function getResolverContainer(Http $utopia): Container { - /** @var callable(): Container $getContainer */ - $getContainer = $utopia->getResource('container'); + $container = $utopia->getResource('container'); - return $getContainer(); + if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) { + /** @var Container $container */ + return $container; + } + + /** @var callable(): Container $container */ + return $container(); } /** @@ -140,33 +145,38 @@ class Resolvers ?Route $route, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $route, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $path = $route->getPath(); - foreach ($args as $key => $value) { - if (\str_contains($path, '/:' . $key)) { - $path = \str_replace(':' . $key, $value, $path); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($route, $args): void { + $path = $route->getPath(); + foreach ($args as $key => $value) { + if (\str_contains($path, '/:' . $key)) { + $path = \str_replace(':' . $key, $value, $path); + } + } + + $request->setMethod($route->getMethod()); + $request->setURI($path); + + switch ($route->getMethod()) { + case 'GET': + $request->setQueryString($args); + break; + default: + $request->setPayload($args); + break; + } } - } - - $request->setMethod($route->getMethod()); - $request->setURI($path); - - switch ($route->getMethod()) { - case 'GET': - $request->setQueryString($args); - break; - default: - $request->setPayload($args); - break; - } - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + ); }); })(); } @@ -209,17 +219,22 @@ class Resolvers callable $url, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -242,22 +257,28 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setQueryString($params($databaseId, $collectionId, $args)); - $beforeResolve = function ($payload) { return $payload['documents']; }; - self::resolve($utopia, $request, $response, $lock, $resolve, $reject, $beforeResolve); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + beforeResolve: $beforeResolve, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setQueryString($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -280,18 +301,23 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('POST'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('POST'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -314,18 +340,23 @@ class Resolvers callable $params, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('PATCH'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('PATCH'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -346,17 +377,22 @@ class Resolvers callable $url, ): callable { return static fn ($type, $args, $context, $info) => (function () use ($utopia, $databaseId, $collectionId, $url, $args) { - $lock = self::getLock($utopia); - - return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args, $lock) { + return new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { $utopia = $utopia->getResource('utopia:graphql'); $request = $utopia->getResource('request'); $response = $utopia->getResource('response'); - $request->setMethod('DELETE'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $lock, $resolve, $reject); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('DELETE'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); }); })(); } @@ -365,11 +401,10 @@ class Resolvers * @param Http $utopia * @param Request $request * @param Response $response - * @param ResolverLock $lock * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve - * @param callable|null $beforeReject + * @param callable|null $prepareRequest * @return void * @throws Exception */ @@ -377,23 +412,28 @@ class Resolvers Http $utopia, Request $request, Response $response, - ResolverLock $lock, callable $resolve, callable $reject, ?callable $beforeResolve = null, - ?callable $beforeReject = null, + ?callable $prepareRequest = null, ): void { - // Drop json content type so post args are used directly - if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { - $request->removeHeader('content-type'); - } - - $request = clone $request; - $resolverResponse = self::createResolverResponse($utopia); - $container = self::getResolverContainer($utopia); + $lock = self::getLock($utopia); self::acquireLock($lock); try { + $request = clone $request; + + // Drop json content type so post args are used directly. + if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { + $request->removeHeader('content-type'); + } + + if ($prepareRequest) { + $prepareRequest($request); + } + + $resolverResponse = self::createResolverResponse($utopia); + $container = self::getResolverContainer($utopia); $container->set('request', static fn () => $request); $container->set('response', static fn () => $resolverResponse); $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); @@ -406,12 +446,18 @@ class Resolvers self::mergeResponseSideEffects($resolverResponse, $response); + if ($resolverResponse->isSent()) { + $response + ->setStatusCode($resolverResponse->getStatusCode()) + ->markSent(); + + $resolve(null); + return; + } + $payload = $resolverResponse->getPayload(); $statusCode = $resolverResponse->getStatusCode(); } catch (\Throwable $e) { - if ($beforeReject) { - $e = $beforeReject($e); - } $reject($e); return; } finally { @@ -419,9 +465,6 @@ class Resolvers } if ($statusCode < 200 || $statusCode >= 400) { - if ($beforeReject) { - $payload = $beforeReject($payload); - } $reject(new GQLException( message: $payload['message'], code: $statusCode diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php index a58c7c29a8..579969cd7b 100644 --- a/src/Appwrite/Promises/Promise.php +++ b/src/Appwrite/Promises/Promise.php @@ -19,8 +19,7 @@ abstract class Promise return; } $resolve = function ($value) { - $this->setResult($value); - $this->setState(self::STATE_FULFILLED); + $this->setState($this->setResult($value)); }; $reject = function ($value) { $this->setResult($value); @@ -106,6 +105,11 @@ abstract class Promise } $callable = $this->isFulfilled() ? $onFulfilled : $onRejected; if (!\is_callable($callable)) { + if ($this->isRejected()) { + $reject($this->result); + return; + } + $resolve($this->result); return; } @@ -126,30 +130,36 @@ abstract class Promise abstract public static function all(iterable $promises): self; /** - * Set resolved result + * Set the resolved result, adopting nested promises while preserving + * whether the adopted promise fulfilled or rejected. * * @param mixed $value - * @return void + * @return int */ - protected function setResult(mixed $value): void + protected function setResult(mixed $value): int { if (!\is_callable([$value, 'then'])) { $this->result = $value; - return; + return self::STATE_FULFILLED; } - $resolved = false; + $state = self::STATE_PENDING; - $callable = function ($value) use (&$resolved) { - $this->setResult($value); - $resolved = true; - }; + $value->then( + function ($value) use (&$state) { + $state = $this->setResult($value); + }, + function ($value) use (&$state) { + $this->result = $value; + $state = self::STATE_REJECTED; + } + ); - $value->then($callable, $callable); - - while (!$resolved) { + while ($state === self::STATE_PENDING) { usleep(25000); } + + return $state; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 5cd0e8366a..2e920a8cc7 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -613,6 +613,8 @@ class Response extends SwooleResponse throw new \Exception('Response body is not a valid JSON object.'); } + $this->payload = \is_array($data) ? $data : (array) $data; + $this ->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8) ->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @@ -637,6 +639,16 @@ class Response extends SwooleResponse return $this; } + /** + * Mark the response as already sent so later callers do not attempt to + * write a second payload to the same underlying Swoole response. + */ + public function markSent(): static + { + $this->sent = true; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. *