From a31e628b578dc81ae55af47410f18d9b4a1bb631 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:01:45 +0100 Subject: [PATCH 1/3] fix(swoole): bound the per-connection output buffer A websocket client that stops draining makes the Swoole reactor buffer every undelivered frame in process memory, without limit, while push() keeps returning true. Reproduced in Appwrite's production image (PHP 8.5.9, Swoole 6.2.2): 300 non-draining connections fanned 2930MB and grew RSS by 2.27GB, all of it in the reactor rather than the PHP worker, with 60000/60000 pushes reported successful. This is the cause of repeated realtime OOMKills. setSocketBufferSize() caps it. Memory then bounds at size x connections, and push() returns false for a connection that is over budget: uncapped RSS +2271MB 60000 ok / 0 failed cap 1MB RSS +333MB 22200 ok / 37800 failed cap 1MB + close RSS +333MB 22200 ok / 300 failed, 300 shed send() now acts on that false. Dropping the frame would leave the client silently out of sync -- in the capped-but-not-closed run above, 37500 events vanished from clients that stayed connected. Closing lets them reconnect and resubscribe from a known state, which for a realtime protocol is the recoverable failure. send_yield is disabled alongside the cap. Left on, an over-budget push suspends and the worker accumulates the backlog against PHP's memory_limit, which fatals the worker instead of shedding one connection. Co-Authored-By: Claude Opus 5 (1M context) --- src/WebSocket/Adapter/Swoole.php | 48 ++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/WebSocket/Adapter/Swoole.php b/src/WebSocket/Adapter/Swoole.php index fc891e4..0916ebe 100644 --- a/src/WebSocket/Adapter/Swoole.php +++ b/src/WebSocket/Adapter/Swoole.php @@ -47,14 +47,24 @@ public function send(array $connections, string $message): void foreach ($connections as $connection) { go(function () use ($connection, $message, $flags) { - if ($this->server->exist($connection) && $this->server->isEstablished($connection)) { - $this->server->push( - $connection, - $message, - SWOOLE_WEBSOCKET_OPCODE_TEXT, - $flags - ); - } else { + if (!$this->server->exist($connection) || !$this->server->isEstablished($connection)) { + $this->server->close($connection); + + return; + } + + $pushed = $this->server->push( + $connection, + $message, + SWOOLE_WEBSOCKET_OPCODE_TEXT, + $flags + ); + + // Only reachable once setSocketBufferSize() has capped the buffer. + // The client is far enough behind that its output buffer is full; + // dropping the frame would leave it silently out of sync, so close + // and let it reconnect from a known state. + if (!$pushed) { $this->server->close($connection); } }); @@ -146,6 +156,28 @@ public function setCompressionEnabled(bool $enabled): self return $this; } + /** + * Caps the per-connection output buffer. + * + * Uncapped, a client that stops draining makes the reactor buffer every + * undelivered frame in process memory without limit, while push() still + * reports success: 300 non-draining connections held 2.27GB, all of it in + * the reactor rather than the PHP worker. Capped, memory is bounded by + * size x connections and push() returns false for a connection that is + * over, which send() turns into a close. + * + * `send_yield` is disabled alongside it. Left on, an over-budget push + * suspends and the worker accumulates the backlog against PHP's + * memory_limit instead, which fatals rather than shedding the connection. + */ + public function setSocketBufferSize(int $bytes): self + { + $this->config['socket_buffer_size'] = $bytes; + $this->config['send_yield'] = false; + + return $this; + } + public function setWorkerNumber(int $num): self { $this->config['worker_num'] = $num; From 5968f4405fa047cd35db92c633ffd8a7ad58fa93 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:22:06 +0100 Subject: [PATCH 2/3] refactor(swoole): take the buffer cap at construction Swoole reads the config once, when start() hands it over, so a setter called after that point silently does nothing. Taking it as a constructor argument makes the value immutable and the timing unambiguous. Default 0 leaves the buffer uncapped, so behaviour is unchanged for existing callers. Co-Authored-By: Claude Opus 5 (1M context) --- src/WebSocket/Adapter/Swoole.php | 47 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/WebSocket/Adapter/Swoole.php b/src/WebSocket/Adapter/Swoole.php index 0916ebe..9c1115d 100644 --- a/src/WebSocket/Adapter/Swoole.php +++ b/src/WebSocket/Adapter/Swoole.php @@ -17,7 +17,20 @@ class Swoole extends Adapter protected int $port; - public function __construct(string $host = '0.0.0.0', int $port = 80) + /** + * @param int $socketBufferSize Bytes the reactor may hold per connection for a + * client that is not keeping up; 0 leaves it uncapped, which is Swoole's default. + * + * Uncapped, a client that stops draining makes the reactor buffer every + * undelivered frame in process memory without limit while push() still reports + * success: 300 non-draining connections held 2.27GB, all of it in the reactor + * rather than the PHP worker. Capped, memory bounds at size x connections and + * push() returns false for a connection that is over, which send() turns into a + * close. Taken here rather than through a setter because it is read once, when + * start() hands the config to Swoole, so changing it later would silently do + * nothing. + */ + public function __construct(string $host = '0.0.0.0', int $port = 80, int $socketBufferSize = 0) { parent::__construct($host, $port); @@ -25,6 +38,14 @@ public function __construct(string $host = '0.0.0.0', int $port = 80) // Set maximum connections to Swoole's limit of 1 Million $this->config['max_connection'] = 1_000_000; + + if ($socketBufferSize > 0) { + $this->config['socket_buffer_size'] = $socketBufferSize; + // Left on, an over-budget push suspends and the worker accumulates the + // backlog against PHP's memory_limit, fatalling the worker instead of + // shedding the one connection that is behind. + $this->config['send_yield'] = false; + } } public function start(): void @@ -60,7 +81,7 @@ public function send(array $connections, string $message): void $flags ); - // Only reachable once setSocketBufferSize() has capped the buffer. + // Only reachable when the buffer is capped (see $socketBufferSize). // The client is far enough behind that its output buffer is full; // dropping the frame would leave it silently out of sync, so close // and let it reconnect from a known state. @@ -156,28 +177,6 @@ public function setCompressionEnabled(bool $enabled): self return $this; } - /** - * Caps the per-connection output buffer. - * - * Uncapped, a client that stops draining makes the reactor buffer every - * undelivered frame in process memory without limit, while push() still - * reports success: 300 non-draining connections held 2.27GB, all of it in - * the reactor rather than the PHP worker. Capped, memory is bounded by - * size x connections and push() returns false for a connection that is - * over, which send() turns into a close. - * - * `send_yield` is disabled alongside it. Left on, an over-budget push - * suspends and the worker accumulates the backlog against PHP's - * memory_limit instead, which fatals rather than shedding the connection. - */ - public function setSocketBufferSize(int $bytes): self - { - $this->config['socket_buffer_size'] = $bytes; - $this->config['send_yield'] = false; - - return $this; - } - public function setWorkerNumber(int $num): self { $this->config['worker_num'] = $num; From 49581ca32300c5079e1020065940af7e214e8d2c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:49:34 +0100 Subject: [PATCH 3/3] fix(swoole): default the cap to 512KB and apply it to the listen port Two corrections to the previous commits. The cap has to be set on the listen port, not the server. ListenPort captures Socket::default_buffer_size when it is constructed (include/swoole_server.h:200), and the server-level socket_buffer_size option only mutates that static later, from start() (ext-src/php_swoole.cc:478) -- by which point the port has its own copy. Set there it silently does nothing, which is why a 512KB server-level value measured identically to no cap at all. The behaviour it guards against is also not "unbounded", as the earlier message said. Swoole's default is SW_SOCKET_BUFFER_SIZE, 8MB per connection (include/swoole_config.h:43), and the overflow check compares out_buffer->length() against it (src/server/master.cc:1561). So exposure is 8MB x connections: fine for a handful, 9.6GB for the ~1200 connections an Appwrite realtime container holds. Verified through Server + Adapter\Swoole in Appwrite's production image, 300 connections that never read, 200 events of 50KB: cap 0 (Swoole 8MB) RSS +2269MB 301 connections still open cap 512KB (default) RSS +176MB 1 connection still open Default is now 512KB rather than opt-in, so the failure mode is bounded without every caller having to know about it. Pass 0 for the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- src/WebSocket/Adapter/Swoole.php | 49 ++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/WebSocket/Adapter/Swoole.php b/src/WebSocket/Adapter/Swoole.php index 9c1115d..e211fbc 100644 --- a/src/WebSocket/Adapter/Swoole.php +++ b/src/WebSocket/Adapter/Swoole.php @@ -11,6 +11,16 @@ class Swoole extends Adapter { + /** + * Bytes the reactor may hold per connection for a client that is not keeping up. + * + * Swoole's own default is SW_SOCKET_BUFFER_SIZE, 8MB per connection, so a server + * holding a few thousand connections can be asked for tens of gigabytes before it + * refuses anything. 512KB is roughly ten typical frames: enough to ride out a + * burst, small enough that stalled connections cannot exhaust a container. + */ + public const DEFAULT_SOCKET_BUFFER_SIZE = 524288; + protected Server $server; protected string $host; @@ -19,19 +29,26 @@ class Swoole extends Adapter /** * @param int $socketBufferSize Bytes the reactor may hold per connection for a - * client that is not keeping up; 0 leaves it uncapped, which is Swoole's default. + * client that is not keeping up. Pass 0 to keep Swoole's 8MB-per-connection + * default. + * + * A client that stops draining makes the reactor buffer its undelivered frames + * in process memory, up to this size, while push() still reports success. At + * Swoole's default, 300 non-draining connections held 2.27GB -- all of it in the + * reactor rather than the PHP worker, so PHP's memory_limit never notices. Total + * exposure is size x connections, and push() starts returning false for a + * connection that is over, which send() turns into a close. * - * Uncapped, a client that stops draining makes the reactor buffer every - * undelivered frame in process memory without limit while push() still reports - * success: 300 non-draining connections held 2.27GB, all of it in the reactor - * rather than the PHP worker. Capped, memory bounds at size x connections and - * push() returns false for a connection that is over, which send() turns into a - * close. Taken here rather than through a setter because it is read once, when - * start() hands the config to Swoole, so changing it later would silently do - * nothing. + * Taken at construction because it has to be applied to the listen port before + * start(): ListenPort captures Socket::default_buffer_size when it is built, and + * the server-level socket_buffer_size option only mutates that static afterwards, + * so setting it there silently does nothing. */ - public function __construct(string $host = '0.0.0.0', int $port = 80, int $socketBufferSize = 0) - { + public function __construct( + string $host = '0.0.0.0', + int $port = 80, + int $socketBufferSize = self::DEFAULT_SOCKET_BUFFER_SIZE, + ) { parent::__construct($host, $port); $this->server = new Server($this->host, $this->port); @@ -40,10 +57,12 @@ public function __construct(string $host = '0.0.0.0', int $port = 80, int $socke $this->config['max_connection'] = 1_000_000; if ($socketBufferSize > 0) { - $this->config['socket_buffer_size'] = $socketBufferSize; - // Left on, an over-budget push suspends and the worker accumulates the - // backlog against PHP's memory_limit, fatalling the worker instead of - // shedding the one connection that is behind. + // On the port, not the server -- see the constructor docblock. + $this->server->ports[0]->set(['socket_buffer_size' => $socketBufferSize]); + // send_yield is a server-level option. Left on, an over-budget push + // suspends and the worker accumulates the backlog against PHP's + // memory_limit, fatalling the worker instead of shedding the one + // connection that is behind. $this->config['send_yield'] = false; } }