From bd1a4cce8e28c5ab11a24042e78ae98ffe92b2d1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 20:27:20 +0530 Subject: [PATCH 1/4] fix: fail fast on Mongo socket receive timeouts Stop retrying recv() false up to 10k times after the socket timeout already elapsed, which could hang Appwrite requests past the HTTP client's 120s limit. Co-authored-by: Cursor --- src/Client.php | 68 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/Client.php b/src/Client.php index f3f05e4..eed238f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -43,6 +43,15 @@ class Client */ private bool $isConnected = false; + /** + * Socket / receive timeout in seconds. + * + * Applied to the Swoole client `timeout` option and used as a wall-clock + * deadline in receive(). recv() returning false already means this wait + * elapsed — receive() must not multiply it with thousands of retries. + */ + private float $receiveTimeout = 30.0; + /** * Defines commands Mongo uses over wire protocol. */ @@ -200,7 +209,7 @@ public function __construct( 'tcp_keepidle' => 4, // Start keepalive after 4s idle 'tcp_keepinterval' => 3, // Keepalive interval 3s 'tcp_keepcount' => 2, // Close after 2 failed keepalives - 'timeout' => 30 // 30 second connection timeout + 'timeout' => $this->receiveTimeout, ]; if ($tls) { @@ -417,11 +426,16 @@ public function send(mixed $data): stdClass|array|int // If send fails, try to reconnect once if ($result === false) { + $errCode = $this->client->errCode ?? 0; $this->close(); $this->connect(); $result = $this->client->send($data); if ($result === false) { - throw new Exception('Failed to send data to MongoDB after reconnection attempt'); + $errCode = $this->client->errCode ?? $errCode; + throw new Exception( + 'Failed to send data to MongoDB after reconnection attempt' + . ($errCode !== 0 ? " (errCode={$errCode})" : '') + ); } } @@ -430,6 +444,13 @@ public function send(mixed $data): stdClass|array|int /** * Receive a message from connection. + * + * Swoole's socket `timeout` already bounds a single recv(). Treating false + * (or a timed-out empty read) as "try again" up to 10k times multiplies + * that into multi-minute hangs (observed as Appwrite create requests + * returning 0 bytes until the HTTP client's 120s curl timeout fires under + * Mongo load). + * * @throws Exception */ private function receive(): stdClass|array|int @@ -437,33 +458,44 @@ private function receive(): stdClass|array|int $chunks = []; $receivedLength = 0; $responseLength = null; - $attempts = 0; - $maxAttempts = 10000; - $sleepTime = 100; + $deadline = \microtime(true) + $this->receiveTimeout; do { + if (\microtime(true) >= $deadline) { + throw new Exception('Receive timeout: no data received within reasonable time'); + } + $chunk = @$this->client->recv(); + $errCode = $this->client->errCode ?? 0; - if ($chunk === false || $chunk === '') { - $attempts++; - if ($attempts >= $maxAttempts) { - throw new Exception('Receive timeout: no data received within reasonable time'); - } + // false => socket-level wait already elapsed with no payload. + // Do not retry: that turns one timeout into thousands. + if ($chunk === false) { + throw new Exception( + 'Receive timeout: no data received within reasonable time' + . ($errCode !== 0 ? " (errCode={$errCode})" : '') + ); + } - // Adaptive backoff: shorter delays for coroutines, longer for sync + // Some Swoole builds return "" on recv timeout instead of false. + // errCode != 0 distinguishes that from a transient empty read. + if ($chunk === '' && $errCode !== 0) { + throw new Exception( + 'Receive timeout: no data received within reasonable time' + . " (errCode={$errCode})" + ); + } + + // Transient empty read with no error; yield until deadline. + if ($chunk === '') { if ($this->client instanceof CoroutineClient) { - Coroutine::sleep(0.001); // 1ms for coroutines + Coroutine::sleep(0.001); } else { - \usleep((int)$sleepTime); // Microsecond precision for sync client - $sleepTime = (int)\min($sleepTime * 1.2, 10000); // Cap at 10ms for faster checking + \usleep(1000); } continue; } - // Reset attempts counter when we receive data - $attempts = 0; - $sleepTime = 100; // Reset to 0.1ms - $chunkLen = \strlen($chunk); $receivedLength += $chunkLen; $chunks[] = $chunk; From 47f05098d4916d0123330b0278b032ced9d0049c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 20:39:36 +0530 Subject: [PATCH 2/4] fix: idle receive timeout and make it configurable Reset the receive deadline on each chunk so large transfers are not cut off mid-stream, expose setTimeout/getTimeout, and use Mongo socket codes so isTimeoutError()/isNetworkError() classify these failures. Co-authored-by: Cursor --- src/Client.php | 56 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/Client.php b/src/Client.php index eed238f..7497076 100644 --- a/src/Client.php +++ b/src/Client.php @@ -44,11 +44,13 @@ class Client private bool $isConnected = false; /** - * Socket / receive timeout in seconds. + * Socket / receive idle timeout in seconds. * - * Applied to the Swoole client `timeout` option and used as a wall-clock + * Applied to the Swoole client `timeout` option and used as an idle * deadline in receive(). recv() returning false already means this wait * elapsed — receive() must not multiply it with thousands of retries. + * The deadline resets whenever a real chunk arrives so large responses + * can still complete under load. */ private float $receiveTimeout = 30.0; @@ -230,6 +232,32 @@ public function __construct( ]); } + /** + * Set the socket / receive idle timeout in seconds. + * + * Updates both the Swoole client `timeout` option and the idle deadline + * used by receive(). Must be > 0. + */ + public function setTimeout(float $seconds): self + { + if ($seconds <= 0) { + throw new \InvalidArgumentException('Timeout must be greater than 0'); + } + + $this->receiveTimeout = $seconds; + $this->client->set(['timeout' => $this->receiveTimeout]); + + return $this; + } + + /** + * Get the socket / receive idle timeout in seconds. + */ + public function getTimeout(): float + { + return $this->receiveTimeout; + } + /** * Connect to MongoDB using TCP/IP and Wire Protocol. * @throws Exception @@ -431,10 +459,12 @@ public function send(mixed $data): stdClass|array|int $this->connect(); $result = $this->client->send($data); if ($result === false) { - $errCode = $this->client->errCode ?? $errCode; + // Prefer the first non-zero errCode (retry may report 0). + $errCode = ($this->client->errCode ?? 0) ?: $errCode; throw new Exception( 'Failed to send data to MongoDB after reconnection attempt' - . ($errCode !== 0 ? " (errCode={$errCode})" : '') + . ($errCode !== 0 ? " (errCode={$errCode})" : ''), + $errCode !== 0 ? $errCode : 9001 // SocketException ); } } @@ -451,6 +481,10 @@ public function send(mixed $data): stdClass|array|int * returning 0 bytes until the HTTP client's 120s curl timeout fires under * Mongo load). * + * The idle deadline resets whenever a real chunk arrives so large + * responses can finish under load; we only fail when the socket goes + * silent for `$receiveTimeout` seconds. + * * @throws Exception */ private function receive(): stdClass|array|int @@ -462,7 +496,7 @@ private function receive(): stdClass|array|int do { if (\microtime(true) >= $deadline) { - throw new Exception('Receive timeout: no data received within reasonable time'); + throw new Exception('Receive timeout: no data received within reasonable time', 11601); } $chunk = @$this->client->recv(); @@ -473,7 +507,8 @@ private function receive(): stdClass|array|int if ($chunk === false) { throw new Exception( 'Receive timeout: no data received within reasonable time' - . ($errCode !== 0 ? " (errCode={$errCode})" : '') + . ($errCode !== 0 ? " (errCode={$errCode})" : ''), + 11601 // SocketTimeout ); } @@ -482,11 +517,12 @@ private function receive(): stdClass|array|int if ($chunk === '' && $errCode !== 0) { throw new Exception( 'Receive timeout: no data received within reasonable time' - . " (errCode={$errCode})" + . " (errCode={$errCode})", + 11601 // SocketTimeout ); } - // Transient empty read with no error; yield until deadline. + // Transient empty read with no error; yield until idle deadline. if ($chunk === '') { if ($this->client instanceof CoroutineClient) { Coroutine::sleep(0.001); @@ -496,6 +532,10 @@ private function receive(): stdClass|array|int continue; } + // Activity: extend idle deadline so large multi-chunk responses + // are not cut off by a fixed wall-clock budget from the first byte. + $deadline = \microtime(true) + $this->receiveTimeout; + $chunkLen = \strlen($chunk); $receivedLength += $chunkLen; $chunks[] = $chunk; From c88e80e842ff506ffbae7ef161644ae7785ffa29 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 20:44:43 +0530 Subject: [PATCH 3/4] refactor: pass receive timeout via Client constructor Replace setTimeout/getTimeout with an optional trailing $timeout constructor argument (default 30s) so callers configure it at connect time. Co-authored-by: Cursor --- src/Client.php | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/src/Client.php b/src/Client.php index 7497076..7b85556 100644 --- a/src/Client.php +++ b/src/Client.php @@ -150,6 +150,7 @@ class Client * @param string|null $authSource Database to authenticate against; defaults to 'admin'. * Set this when the user was created in a database other than admin (e.g. the * application database itself). + * @param float $timeout Socket / receive idle timeout in seconds (default 30). * @throws \Exception */ public function __construct( @@ -161,7 +162,8 @@ public function __construct( bool $useCoroutine = false, bool $tls = false, array $tlsOptions = [], - ?string $authSource = null + ?string $authSource = null, + float $timeout = 30.0 ) { if (empty($database)) { throw new \InvalidArgumentException('Database name cannot be empty'); @@ -178,11 +180,15 @@ public function __construct( if (empty($password)) { throw new \InvalidArgumentException('Password cannot be empty'); } + if ($timeout <= 0) { + throw new \InvalidArgumentException('Timeout must be greater than 0'); + } $this->id = uniqid('utopia.mongo.client'); $this->database = $database; $this->host = $host; $this->port = $port; + $this->receiveTimeout = $timeout; // Only use coroutines if explicitly requested and we're in a coroutine context if ($useCoroutine) { @@ -232,32 +238,6 @@ public function __construct( ]); } - /** - * Set the socket / receive idle timeout in seconds. - * - * Updates both the Swoole client `timeout` option and the idle deadline - * used by receive(). Must be > 0. - */ - public function setTimeout(float $seconds): self - { - if ($seconds <= 0) { - throw new \InvalidArgumentException('Timeout must be greater than 0'); - } - - $this->receiveTimeout = $seconds; - $this->client->set(['timeout' => $this->receiveTimeout]); - - return $this; - } - - /** - * Get the socket / receive idle timeout in seconds. - */ - public function getTimeout(): float - { - return $this->receiveTimeout; - } - /** * Connect to MongoDB using TCP/IP and Wire Protocol. * @throws Exception From 9662b1e26c4090234dd3370a220c5dd86138758b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 20:46:06 +0530 Subject: [PATCH 4/4] refactor: rename receiveTimeout property to timeout Co-authored-by: Cursor --- src/Client.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Client.php b/src/Client.php index 7b85556..149b5d1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -52,7 +52,7 @@ class Client * The deadline resets whenever a real chunk arrives so large responses * can still complete under load. */ - private float $receiveTimeout = 30.0; + private float $timeout = 30.0; /** * Defines commands Mongo uses over wire protocol. @@ -188,7 +188,7 @@ public function __construct( $this->database = $database; $this->host = $host; $this->port = $port; - $this->receiveTimeout = $timeout; + $this->timeout = $timeout; // Only use coroutines if explicitly requested and we're in a coroutine context if ($useCoroutine) { @@ -217,7 +217,7 @@ public function __construct( 'tcp_keepidle' => 4, // Start keepalive after 4s idle 'tcp_keepinterval' => 3, // Keepalive interval 3s 'tcp_keepcount' => 2, // Close after 2 failed keepalives - 'timeout' => $this->receiveTimeout, + 'timeout' => $this->timeout, ]; if ($tls) { @@ -463,7 +463,7 @@ public function send(mixed $data): stdClass|array|int * * The idle deadline resets whenever a real chunk arrives so large * responses can finish under load; we only fail when the socket goes - * silent for `$receiveTimeout` seconds. + * silent for `$timeout` seconds. * * @throws Exception */ @@ -472,7 +472,7 @@ private function receive(): stdClass|array|int $chunks = []; $receivedLength = 0; $responseLength = null; - $deadline = \microtime(true) + $this->receiveTimeout; + $deadline = \microtime(true) + $this->timeout; do { if (\microtime(true) >= $deadline) { @@ -514,7 +514,7 @@ private function receive(): stdClass|array|int // Activity: extend idle deadline so large multi-chunk responses // are not cut off by a fixed wall-clock budget from the first byte. - $deadline = \microtime(true) + $this->receiveTimeout; + $deadline = \microtime(true) + $this->timeout; $chunkLen = \strlen($chunk); $receivedLength += $chunkLen;