diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 6297da2cfe..9268309552 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0 ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html ARG NGINX_VERSION=1.31.2-r1 +# https://pecl.php.net/package/uv +ARG PHP_UV_VERSION=0.3.0 # ================================================================= # Get MinIO client @@ -26,6 +28,7 @@ ARG TARGETARCH ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION ARG NGINX_VERSION +ARG PHP_UV_VERSION WORKDIR /var/www/html @@ -70,7 +73,8 @@ RUN apk add --no-cache \ vim # Install PHP extensions -RUN install-php-extensions sockets +# uv: Reverb uses an ext-uv event loop instead of stream_select(), which fails at 1024 open files (FD_SETSIZE) +RUN install-php-extensions sockets uv-${PHP_UV_VERSION} # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ diff --git a/docker/development/etc/s6-overlay/s6-rc.d/reverb/run b/docker/development/etc/s6-overlay/s6-rc.d/reverb/run index e35e88a6cb..270d248a97 100755 --- a/docker/development/etc/s6-overlay/s6-rc.d/reverb/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/reverb/run @@ -2,5 +2,21 @@ cd /var/www/html +# Every WebSocket connection uses a file descriptor. Raise the soft open-file limit (often 1024) +# up to the hard limit, at most 65536. Never lower it, and start Reverb even when this fails. +# Only with an event loop extension (uv, ev, event): the default stream_select() loop fails for +# every connection once one descriptor is 1024 or higher (FD_SETSIZE), so it keeps the limit. +if php -r 'exit(function_exists("uv_loop_new") || class_exists("EvLoop", false) || class_exists("EventBase", false) ? 0 : 1);'; then + target=65536 + hard=$(ulimit -Hn) + if [ "$hard" != "unlimited" ] && [ "$hard" -lt "$target" ] 2>/dev/null; then + target=$hard + fi + soft=$(ulimit -Sn) + if [ "$soft" != "unlimited" ] && [ "$soft" -lt "$target" ] 2>/dev/null; then + ulimit -Sn "$target" 2>/dev/null || echo " WARN Could not raise the open-file limit of Reverb from $soft to $target." + fi +fi + echo " INFO Reverb is starting..." exec php artisan reverb:start --host=0.0.0.0 --port=${PUSHER_BACKEND_PORT:-6001} diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 03d1145df7..a751000bff 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2026.7.3 ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html ARG NGINX_VERSION=1.31.2-r1 +# https://pecl.php.net/package/uv +ARG PHP_UV_VERSION=0.3.0 # Add user/group ARG USER_ID=9999 @@ -74,6 +76,7 @@ ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION ARG NGINX_VERSION +ARG PHP_UV_VERSION ARG COOLIFY_VERSION ARG CI=true @@ -120,6 +123,10 @@ RUN --mount=type=cache,target=/var/cache/apk \ lsof \ vim +# Install PHP extensions +# uv: Reverb uses an ext-uv event loop instead of stream_select(), which fails at 1024 open files (FD_SETSIZE) +RUN install-php-extensions uv-${PHP_UV_VERSION} + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/docker/production/etc/s6-overlay/s6-rc.d/reverb/run b/docker/production/etc/s6-overlay/s6-rc.d/reverb/run index e35e88a6cb..270d248a97 100755 --- a/docker/production/etc/s6-overlay/s6-rc.d/reverb/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/reverb/run @@ -2,5 +2,21 @@ cd /var/www/html +# Every WebSocket connection uses a file descriptor. Raise the soft open-file limit (often 1024) +# up to the hard limit, at most 65536. Never lower it, and start Reverb even when this fails. +# Only with an event loop extension (uv, ev, event): the default stream_select() loop fails for +# every connection once one descriptor is 1024 or higher (FD_SETSIZE), so it keeps the limit. +if php -r 'exit(function_exists("uv_loop_new") || class_exists("EvLoop", false) || class_exists("EventBase", false) ? 0 : 1);'; then + target=65536 + hard=$(ulimit -Hn) + if [ "$hard" != "unlimited" ] && [ "$hard" -lt "$target" ] 2>/dev/null; then + target=$hard + fi + soft=$(ulimit -Sn) + if [ "$soft" != "unlimited" ] && [ "$soft" -lt "$target" ] 2>/dev/null; then + ulimit -Sn "$target" 2>/dev/null || echo " WARN Could not raise the open-file limit of Reverb from $soft to $target." + fi +fi + echo " INFO Reverb is starting..." exec php artisan reverb:start --host=0.0.0.0 --port=${PUSHER_BACKEND_PORT:-6001} diff --git a/tests/Feature/ReverbOpenFileLimitTest.php b/tests/Feature/ReverbOpenFileLimitTest.php new file mode 100644 index 0000000000..f8231b5b24 --- /dev/null +++ b/tests/Feature/ReverbOpenFileLimitTest.php @@ -0,0 +1,57 @@ +bin = sys_get_temp_dir().'/reverb-open-files-'.bin2hex(random_bytes(4)); + File::makeDirectory($this->bin); + // Answers the event loop extension probe (`php -r`) from FAKE_LOOP_EXTENSION, and stands in for + // `php artisan reverb:start` by reporting the limit Reverb would get. + File::put("{$this->bin}/php", "#!/bin/sh\nif [ \"\$1\" = \"-r\" ]; then [ \"\$FAKE_LOOP_EXTENSION\" = yes ]; exit \$?; fi\necho \"soft=\$(ulimit -Sn)\"\n"); + chmod("{$this->bin}/php", 0755); +}); + +afterEach(function () { + File::deleteDirectory($this->bin); +}); + +function reverbSoftOpenFileLimit(string $runScript, string $hardLimit, string $softLimit, string $bin, bool $loopExtension = true): string +{ + $result = Process::env(['PATH' => "{$bin}:/usr/bin:/bin", 'FAKE_LOOP_EXTENSION' => $loopExtension ? 'yes' : 'no'])->run(sprintf( + // The soft limit is lowered first: a hard limit below the current soft limit is invalid. + 'ulimit -Sn %s && ulimit -Hn %s && exec sh %s', + $softLimit, + $hardLimit, + escapeshellarg(base_path($runScript)), + )); + + expect($result->successful())->toBeTrue(); + + return trim(str($result->output())->afterLast('soft=')); +} + +it('raises the reverb soft open-file limit up to the hard limit, at most 65536', function (string $runScript, string $hardLimit, string $softLimit, string $expected) { + expect(reverbSoftOpenFileLimit($runScript, $hardLimit, $softLimit, $this->bin))->toBe($expected); +})->with([ + 'production' => 'docker/production/etc/s6-overlay/s6-rc.d/reverb/run', + 'development' => 'docker/development/etc/s6-overlay/s6-rc.d/reverb/run', +])->with([ + 'hard limit below the cap' => ['4096', '1024', '4096'], + 'hard limit above the cap' => ['200000', '1024', '65536'], + 'hard limit equal to the soft limit' => ['1024', '1024', '1024'], + 'soft limit already above the cap' => ['200000', '100000', '100000'], +]); + +it('keeps the reverb soft open-file limit without an event loop extension', function (string $runScript) { + expect(reverbSoftOpenFileLimit($runScript, '4096', '1024', $this->bin, loopExtension: false))->toBe('1024'); +})->with([ + 'production' => 'docker/production/etc/s6-overlay/s6-rc.d/reverb/run', + 'development' => 'docker/development/etc/s6-overlay/s6-rc.d/reverb/run', +]);