fix(reverb): use the uv event loop and raise the open-file limit

The default stream_select() loop fails once a descriptor reaches 1024
(FD_SETSIZE). Install ext-uv and, only when an event loop extension is
available, raise the soft open-file limit of Reverb up to 65536.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-02 16:01:20 +02:00
co-authored by Claude Opus 5.5
parent 1501a44cfe
commit a0fa21b448
5 changed files with 101 additions and 1 deletions
+5 -1
View File
@@ -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 && \
@@ -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}
+7
View File
@@ -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 && \
@@ -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}
+57
View File
@@ -0,0 +1,57 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Reverb keeps one file descriptor per WebSocket connection. With an event loop extension its s6 run
* script raises the soft open-file limit up to the hard limit (at most 65536) and still starts when it
* cannot. Without one, ReactPHP uses stream_select(), which fails for every connection once a
* descriptor reaches FD_SETSIZE (1024), so the limit must stay.
*/
beforeEach(function () {
$this->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',
]);