Laravel Octane 2.23.0 виправив баг, який ламав стрімінг під Octane: response()->stream() з генератором на FrankenPHP і Swoole повертав 200 з порожнім тілом. Саме так ламалися SSE-потоки і стрімінг відповідей Laravel AI SDK - а отже, AI-чати й live-оновлення, запущені через Octane.
Що саме не працювало
Laravel вміє стрімити відповідь двома способами: замикання може писати в вивід через echo і flush(), а може повертати генератор і віддавати чанки через yield. Поза Octane обидва варіанти працюють однаково. Під Octane ResponseFactory::stream() поводиться інакше: генераторне замикання він передає серверному клієнту Octane як є і чекає, що клієнт сам пройде генератор.
Як пояснює автор виправлення Паскаль Бальєт у PR #1182, робив це лише клієнт RoadRunner, і то тільки коли в замикання був оголошений тип повернення Generator або string. FrankenPHP і Swoole викликали sendContent() із Symfony, а той викликає замикання, але не ітерує генератор, який воно повертає. Код усередині генератора просто не виконувався, клієнт отримував порожню відповідь без жодної помилки.
Проблему описали в issue laravel/framework#60406: автор натрапив на неї саме з Laravel AI SDK на FrankenPHP. Варіант з echo і flush() при цьому працював, тож обхідний шлях існував. #1182 - уже четверта спроба виправлення після #1141, #1144 і #1163.
Як це працює тепер
На FrankenPHP і Swoole Octane обгортає генераторне замикання: кожен чанк, що вийшов через yield, одразу виводиться і скидається клієнту, так само як Laravel стрімить поза Octane. RoadRunner тепер стрімить будь-яке замикання-генератор, а не лише з оголошеним типом Generator.
Ось маршрут з issue, який до оновлення віддавав порожнє тіло, а тепер працює:
use Illuminate\Support\Facades\Route;
Route::get('/sse-yield', fn () => response()->stream(function (): iterable {
yield "data: hello\n\n";
yield "data: [DONE]\n\n";
}, headers: ['Content-Type' => 'text/event-stream']));
Замикання на echo і flush() поводяться як раніше. Автор перевірив зміну на реальних серверах FrankenPHP 1.13.4, Swoole 6.2.4 і RoadRunner 3.0.1.
Посилення: стрім зупиняється разом із клієнтом
Щойно генератори почали реально виконуватися, стали досяжними крайні випадки, які раніше ніхто не бачив. Їх закрив наступний PR #1183:
- Розрив з'єднання зупиняє стрім. FrankenPHP повідомляє про нього через
connection_aborted(), Swoole - через невдалий write() чергового чанка. Без цього нескінченний SSE-генератор працював і після того, як браузер закрили: на FrankenPHP тримав воркер безстроково, на Swoole - доки його не вбивав сторожовий таймер max_execution_time (за замовчуванням 30 секунд). Блоки finally генератора при цьому виконуються.
- RoadRunner при розриві з'єднання тепер тихо завершує стрім, а не кидає
StreamStoppedException.
- Swoole закриває буфер виводу, коли стрімінгове замикання кидає виняток. Раніше буфер протікав у довгоживучий воркер.
- Octane тепер конфліктує з
spiral/roadrunner-http нижче 3.4.0: у 3.3.0 немає PSR7Worker::getHttpWorker(), тож замикання з : Generator на ній і так падали.
Для SSE це важливіше, ніж здається. Воркерів в Octane небагато, і кожен «завислий» на закритій вкладці потік забирав один із них.
Решта релізу
- RoadRunner 3.x. RoadRunner перейшов з календарних версій (2023.x-2025.x) на семантичні з v3.0.0. Composer вважав
3.0.0 меншою за 2023.1.0, тож Octane конфліктував із новою лінійкою (#1175), а перевірка бінарника вважала rr 3.x застарілим і без TTY щоразу перекачувала його при старті (#1178). Обидва виправлено. Зважте: PHP-пакети RoadRunner 3 потребують PHP 8.2+, а автор #1178 прямо пише, що повної підтримки v3 ще немає - формат JSON-логів у v3 змінився, і writeServerOutput() його поки не враховує.
- FrankenPHP 1.13.2 обов'язковий (#1176). Статичні бінарники FrankenPHP мають власну PHP, і в 1.13.0-1.13.1 вона вразлива до PHP2Shell - двох критичних RCE без автентифікації. Оновлення системної PHP тут не допомагає. Зі старішим бінарником
octane:start попередить і запропонує завантажити свіжий реліз.
- Бінарники FrankenPHP поза коренем проєкту більше не замінюються (#1177). Раніше Octane перейменовував, наприклад,
/usr/local/bin/frankenphp і завантажував новий у корінь проєкту, після чого сервер не стартував. Тепер для такого бінарника Octane лише попереджає, що його треба оновити вручну.
- Стандартні слухачі як singletons (#1174). 16 із приблизно 40 слухачів події
RequestReceived контейнер збирав через рефлексію на кожен запит. Тепер кожен воркер будує їх один раз.
Як оновитися
composer require laravel/octane:^2.23
Після оновлення перезапустіть воркери (php artisan octane:reload або рестарт сервісу), щоб вони підхопили новий код.
Чи варто оновлюватися
Якщо ви стрімите відповіді під Octane на FrankenPHP чи Swoole - так, і варто одразу перевірити, чи не лишився в коді обхідний шлях з echo і flush(): тепер його можна прибрати. Те саме стосується тих, хто використовує стрімінг Laravel AI SDK.
На FrankenPHP оновлення потрібне і без стрімінгу, через вимогу 1.13.2: це закриває критичну вразливість. Майте на увазі, що під supervisor чи іншим демоном без TTY octane:start автоматично завантажить новий бінарник у корінь проєкту. Якщо FrankenPHP у вас у Docker-образі чи в PATH, оновіть його в образі самі - Octane лише попередить.
На RoadRunner перехід на 3.x тепер можливий, але не поспішайте, якщо ваш моніторинг читає логи Octane: формат логів v3 поки підтримується не повністю.