Запрос кончился, соединение осталось
Рано или поздно в приложении появляется страница, которая должна что-то показывать сама:
уведомление, прогресс длинной задачи, чужой курсор в общем документе. В Rails на это
есть три ответа — ActionController::Live, SSE обычным телом ответа и ActionCable, — и
в описании каждого будет слово «стриминг».
Слово одно, а механизмы под ним разные до противоположности. Первый занимает по два потока на соединение. Второй — по одному. Третий не занимает ни одного, потому что забирает у сервера сокет, а вместе с ним половину того, что сервер за вас делал.
Знать это стоит не ради выбора между ними — выбор обычно уже сделан за вас, — а потому
что третий работает в вашем приложении прямо сейчас, если в Gemfile есть
actioncable. И тогда строка в логе про запрос, завершившийся за три миллисекунды,
относится к соединению, которое проживёт ещё четыре часа.
Механизм, который так умеет, называется rack.hijack.
Всё, что ниже, написано под Rack 3. Rails умеет с ним работать с 7.1, но не требует
его до сих пор: actionpack 8.1 объявляет зависимость rack >= 2.2.4, так что
приложение, доехавшее до Rails 8 с семёрки, скорее всего сидит на Rack 2 и никто ему об
этом не скажет — смотрите Gemfile.lock. Rack 2 отличается, и отличается ровно в тех
местах, где о нём написано больше всего статей, так что различиям отведён отдельный
раздел, а по дороге они помечены.
Контракт, из которого выходят
Контракт Rack умещается в одну строку: приложение получает env и возвращает три
значения — статус, заголовки, тело. В сокет всё это пишет сервер.
def call(env)
[200, { "content-type" => "text/plain" }, ["hi"]]
end
Из того, что сокет принадлежит серверу, растёт всё остальное. Сервер считает одновременные запросы и держит пул потоков нужного размера. Он ставит таймауты. Он решает про keep-alive. При деплое он дожидается тех запросов, что уже начались, и только потом умирает. Приложение ничего этого не знает, и это правильное разделение.
rack.hijack — дверь, через которую приложение из этого контракта выходит.
Мысль простая.
Перехват — не «способ сделать стриминг». Это обмен: приложение забирает сокет и вместе с ним всю бухгалтерию, которую сервер вёл за него. Иногда обмен выгодный.
Полный перехват
env["rack.hijack"] — вызываемый объект. Вызвали — получили сокет.
def call(env)
return [501, {}, []] unless env["rack.hijack"]
io = env["rack.hijack"].call
io.write("HTTP/1.1 101 Switching Protocols\r\n")
io.write("upgrade: websocket\r\n")
io.write("connection: Upgrade\r\n")
io.write("sec-websocket-accept: #{accept}\r\n")
io.write("\r\n")
[-1, {}, []]
end
Первая строка — уже различие версий. В Rack 3 о поддержке полного перехвата говорит само
наличие ключа, отдельного флага нет. В Rack 2 спрашивали env["rack.hijack?"], и он
отвечал сразу за оба перехвата — примеры в старых статьях начинаются именно с него.
Дальше — чего тут нет. Статусной строки никто за вас не напишет: вы её пишете сами,
вместе с \r\n в правильных местах. Content-Length тоже ваш, Transfer-Encoding ваш,
и семантика HTTP/1 целиком ваша. Сервер в этот сокет больше не заглядывает.
[-1, {}, []] — соглашение «я ответил сам», и это соглашение серверов, а не
спецификации: Rack::Lint такой ответ отвергает, статус обязан быть целым числом не
меньше ста. Формально после полного перехвата сервер игнорирует ответ целиком и вернуть
можно что угодно — но пишут -1, потому что его понимают Puma, Unicorn и Thin. Puma
понимает его буквально и проверяет строго: unless headers.empty? and res_body == [] —
иначе исключение. Впрочем, до этой проверки дело обычно не доходит: return :async if client.hijacked стоит раньше.
Ещё одна мелочь, на которой спотыкаются: спецификация обещает IO, а вы его не всегда
получаете. Puma кладёт в env["rack.hijack"] свой Client, и под TLS его call вернёт
Puma::MiniSSL::Socket — писать в него можно, а IO.select его не берёт, нужен
to_io. ActionCable этого не замечает только потому, что to_io за него зовёт nio4r.
HTTP/1 и только HTTP/1, в обеих версиях — хотя вслух это говорит одна: в спецификации Rack 3 такая строка есть, в Rack 2 её нет, потому что HTTP/2 туда просто не входил. Причина не в лени: в HTTP/2 внутри одного TCP-соединения живут несколько запросов одновременно, и «забрать сокет» означало бы забрать заодно и чужие. Забирать нечего.
Частичный перехват
Второй вид выглядит похоже и устроен иначе. Сервер сам пишет статус и заголовки, а дальше отдаёт сокет вам — под тело ответа.
def call(env)
return [501, {}, []] unless env["rack.hijack?"]
body = proc do |stream|
10.times do |i|
stream.write("data: #{i}\n\n")
sleep 1
end
ensure
stream.close
end
[200, { "content-type" => "text/event-stream", "rack.hijack" => body }, []]
end
Ключ rack.hijack тут в заголовках ответа, а не в env, и это разные механизмы с
одинаковым именем. Тело ответа сервер обязан проигнорировать; спецификация советует
класть туда пустой массив.
Проверка rack.hijack? в первой строке не для красоты: если флага нет, заголовок
rack.hijack в ответе присутствовать не должен. Это единственное место, где в Rack 3
этот флаг вообще что-то значит.
И это единственное место, где вам нужен сам механизм. В Rack 3 то же самое пишется без
всякого перехвата: телом ответа может быть вызываемый объект, и тогда сервер зовёт его с
тем же stream.
[200, { "content-type" => "text/event-stream" }, body]
Один и тот же proc, никаких служебных ключей. Внутри Puma это буквально одна и та же
ветка: response_hijack = resp_info[:response_hijack] || res_body. Спецификация говорит
про частичный перехват прямо: функционально это то же самое, что потоковое тело, и
поддерживается он ради совместимости со старыми версиями Rack.
Что тут от Rack 2, а что от Rack 3
Различий немного, и почти каждое объясняет, почему найденный в поиске пример не работает.
| Rack 2 | Rack 3 | |
|---|---|---|
env["rack.hijack?"] |
«сервер умеет перехват» — сразу про оба | только про частичный |
env["rack.hijack"] |
вызвать → полный перехват | то же, и оно же признак поддержки |
env["rack.hijack_io"] |
сервер клал туда сокет | из спецификации убран |
заголовок ответа rack.hijack |
единственный способ писать тело самому | обратная совместимость |
тело ответа как proc |
нет | штатный интерфейс |
env["rack.protocol"] |
нет | появился в 3.1 |
| ключи заголовков | как принято, Content-Type |
только нижний регистр |
Две строки стоит объяснить.
rack.hijack_io убрали не потому, что он лишний. Сервер клал сокет в env уже
после того, как миддлвары успели сделать env.dup, — и класть его было, в общем,
некуда: копия, до которой доедет значение, к этому моменту уже не та, которую читает
приложение. Убрали, впрочем, из спецификации, а не из серверов. Puma его по-прежнему
ставит, ActionCable по-прежнему читает как запасной вариант, и Puma::CommonLogger по
нему опознаёт перехваченный запрос. Так что код, написанный под Rack 2, тут не сломается
— он просто опирается на то, чего ему больше никто не обещает.
Нижний регистр в заголовках — тоже Rack 3. content-type в примерах выше написан
так не из вкуса: в Rack 3 заглавные буквы в ключе ответа запрещены спецификацией, под
Rack 2 тот же пример писался бы как Content-Type. На upgrade и connection из
полного перехвата это не распространяется — там вы пишете байты в сокет сами, и Rack к
ним отношения не имеет.
Только один из двух освобождает поток
Вот это различие обычно и пропускают, а оно главное — и оно не про версии, а про сами механизмы.
Частичный перехват в Puma вызывается в том же потоке, который обрабатывал запрос:
if response_hijack
fast_write_str socket, io_buffer.read_and_reset
uncork_socket socket
response_hijack.call socket
return :async
end
sleep 1 в вашем proc — это спящий поток Puma. Пять потоков в пуле означают пять
одновременных SSE-подписчиков, шестой ждёт. Ровно то же верно и для потокового тела Rack
3, потому что это одна и та же строка кода: переход на новый интерфейс ничего в этой
арифметике не меняет.
Полный перехват — другое дело. Сокет уходит, обработчик возвращается, поток немедленно берёт следующий запрос. Только теперь у вас на руках открытый сокет, который никто не читает.
Так что «перехват освобождает поток» — утверждение про один вид перехвата из двух. Второй просто переносит блокировку из тела ответа в ваш код.
Что из этого делает Rails
Механизмов, которые Rails действительно предоставляет, два: SSE обычным телом ответа — не механизм Rails, а его отсутствие, там один Rack и ничего сверх него. И перехватывает из двух только один.
ActionCable перехватывает полностью. В ActionCable::Connection::Stream:
def hijack_rack_socket
return unless @socket_object.env["rack.hijack"]
@rack_hijack_io = @socket_object.env["rack.hijack"].call
@rack_hijack_io ||= @socket_object.env["rack.hijack_io"] # запасной путь для Rack 2
@event_loop.attach(@rack_hijack_io, self)
end
Дальше client_socket.rb возвращает то самое [-1, {}, []]. attach — ключевое слово:
сокет отдают в цикл событий на nio4r, один на все соединения сразу. Тысяча открытых
WebSocket-соединений — это тысяча файловых дескрипторов и один поток, который их
опрашивает, плюс небольшой пул потоков под саму работу.
ActionController::Live не перехватывает вообще. Он отправляет экшен в свой пул
потоков, рендерит в очередь и отдаёт эту очередь обычным телом ответа. Сокет остаётся у
сервера, со всеми его таймаутами и подсчётами. Цена — два потока на соединение: один в
пуле Puma, который итерирует тело и висит на pop, и один под сам рендер.
Разница ровно в том, где после ответа живёт сокет. Перехват покупает ровно одну вещь: разрыв между «сколько у меня открытых соединений» и «сколько у меня потоков».
Миддлвары кончились
Самая тихая часть.
После полного перехвата обработчик возвращается сразу — и вторая половина каждого
миддлвара отрабатывает прямо сейчас, пока ваш сокет ещё открыт и разговор ещё не
начался. Часть из них ждёт не возврата, а close на теле ответа: Puma закрывает тело в
том же ensure, из которого только что вышла с :async, так что разницы никакой.
Rack::Deflaterникогда не увидит ваших байтов.- Логгер напишет строку о завершившемся запросе. Соединение проживёт после этого ещё четыре часа.
ActionDispatch::Executorзакроет запрос: вернёт соединение ActiveRecord в пул, обнулитCurrentAttributes, отпустит блокировку автозагрузки (эту — только в разработке, в продакшене её никто не брал).
Последнее и есть ловушка. Код, который пишет в перехваченный сокет, работает уже вне запроса, и всё, что Rails держит на времени жизни запроса, к этому моменту разобрано. Обратиться к базе из обработчика WebSocket-сообщения, «как обычно», — значит взять то, чего у вас больше нет.
ActionCable решает это прямо: app.executor.wrap вокруг каждой единицы работы, в
action_cable/engine.rb. Он не продолжает запрос — он заново открывает тот самый
executor, который закрылся в момент перехвата.
Своё, если писать руками, вешать тоже есть куда. Puma кладёт в env массив
rack.after_reply и выполняет всё, что в нём лежит, в том же ensure. Это не часть
спецификации, на другом сервере его не будет — и, что важнее, срабатывает он сразу после
перехвата, а не когда закроется сокет. То есть это крючок на «запрос кончился», а не на
«разговор кончился». Второй придётся заводить самому, и это ровно то место, где обычно и
текут дескрипторы.
Полезная привычка — читать перехват как «здесь запрос закончился». Всё, что дальше, живёт по правилам фоновой задачи, а не контроллера.
Кто теперь читает этот сокет
Никто. Сервер про него забыл — в Puma обе ветки возвращают :async, а :async означает
«не пиши, не закрывай, не считай keep-alive, соединения больше нет».
Вариантов два.
Поток на соединение — самый быстрый способ получить работающий прототип, а заодно и thread-per-connection сервер внутри своего сервера приложений. На десятке соединений незаметно, на тысяче это тысяча стеков.
Цикл событий — то, что делает ActionCable: один селектор на все сокеты, обработчик дёргается по готовности. Дороже написать, дешевле держать.
И закрывать сокет тоже вам. Никто больше этого не сделает, а незакрытый сокет — это дескриптор, которых у процесса конечное число.
Где это ломается
Таймауты не про вас. Сервер не считает это соединение своим, и его таймауты на этот сокет не действуют. Мёртвый клиент, который не прислал FIN, будет числиться живым, пока вы сами не заведёте heartbeat и не начнёте считать, когда он в последний раз отвечал.
Деплой рвёт разговор посередине. Puma при плавном перезапуске дожидается запросов,
которые ещё выполняются. Полностью перехваченный запрос выполненным считается уже давно,
так что ждать его никто не будет — сокет умрёт вместе с процессом. Это не баг, это цена.
Платит её клиент — тем, что умеет переподключаться; клиент ActionCable умеет, самописный
— ровно настолько, насколько вы его написали. С частичным перехватом всё наоборот: он
всё ещё висит на потоке пула, поэтому перезапуск будет ждать вашу SSE-подписку. По
умолчанию — вечно: force_shutdown_after не задан, пока вы его не задали, а это
:forever, и тогда Puma просто делает join.
Два воркера — два острова. Формально это уже про ActionCable, а не про перехват, но
ломается чаще всего именно здесь, и по той же причине: сокеты лежат по процессам.
Адаптер async — тот, что стоит в cable.yml для разработки, — держит подписки в
памяти процесса. Пока Puma работает одним процессом, разницы не видно. Включите
кластерный режим, и broadcast дойдёт только до тех, чьё соединение висит на том же
воркере, который его отправил. Половина клиентов не получит ничего, и ни одной ошибки
нигде не появится. Для этого в продакшене и стоит redis — или Solid Cable, если
приложение сделано на Rails 8. Разницы для рассуждения нет: рассылка должна ходить между
процессами, потому что соединения по ним разошлись.
Прокси буферизует — это про SSE. nginx с настройками по умолчанию соберёт поток
событий в буфер и отдаст одним куском, когда сочтёт нужным; лечится proxy_buffering off или заголовком X-Accel-Buffering: no. И у него свой proxy_read_timeout, который
про долгие соединения ничего не знает. Соединения, ушедшего в WebSocket через 101, это
не касается — там ломается другое: без proxy_http_version 1.1 и проброшенных Upgrade
и Connection переключение просто не состоится.
HTTP/2 закрывает эту дверь. За nginx, который терминирует h2, до Puma всё равно едет
HTTP/1, и перехват работает. На сервере, который сам говорит на HTTP/2, — нет, и
механизм там другой, появившийся в Rack 3.1: сервер кладёт в env["rack.protocol"]
список протоколов, которые предложил клиент, приложение отвечает 101 и одноимённым
заголовком ответа с одним из них, а как выглядит переключение в его версии протокола,
разбирается сервер — в HTTP/1 через upgrade, в HTTP/2 просто приняв запрос. Puma этого
не умеет вовсе; умеет, например, Falcon.
Тесты не помогают. У rack-test нет сокета, поэтому ни rack.hijack, ни
rack.hijack? там не появляются, и вся интересная ветка не выполняется. Интеграционный
тест подтвердит, что вернулось [-1, {}, []], и ни слова не скажет о том, что
происходит дальше. Всё, что после перехвата, проверяется настоящим клиентом по
настоящему сокету.
«Тогда зачем это знать в 2026-м»
Возражение справедливое. Частичный перехват в Rack 3 стал обычным телом ответа. Для
переключения протокола есть rack.protocol. WebSocket-ы за вас уже написал ActionCable,
а на восемь задач из десяти хватает SSE, которому перехват вообще не нужен. Поводов
звать env["rack.hijack"] руками почти не осталось, и это хорошо.
Знать про него надо по другой причине: в вашем приложении он уже вызван. Если в
Gemfile есть actioncable, то всё описанное выше — оборванный стек миддлваров,
executor, который закрылся раньше времени, сокет вне подсчётов сервера, разговор,
который не переживёт деплоя, — уже происходит. Разница только в том, знаете вы об этом
или разбираетесь по логу, где строка «запрос завершён» стоит за час до ошибки.
Итог
Перехват — это обмен ресурса на ресурс. Полный отдаёт поток и забирает сокет вместе с таймаутами, плавным перезапуском и правой половиной каждого миддлвара. Частичный не отдаёт даже потока — он только переставляет блокировку в ваш код, и в Rack 3 честно называется тем, чем всегда был: телом ответа.
Он окупается ровно в одном случае: когда открытых соединений заведомо больше, чем
потоков, и когда есть кому за эти соединения отвечать — цикл событий, heartbeat,
переподключение на клиенте. Всё это придётся написать, и это и есть настоящая цена, а не
одна строчка с .call.
А если соединений столько же, сколько потоков, — не забирайте сокет. Сервер распорядится им лучше.
Комментарии 0
Комментариев пока нет.