все посты
Широкая схема на кремовой бумаге. Слева тёмная войлочная полоса «Standard Rails Request–Response Cycle» с восемью пронумерованными шагами, от «Client: Request» до «Client: Complete». На седьмом шаге из полосы выходит синий кабель с подписью HIJACK PATH и уходит вправо, через три медных иллюминатора — частичный перехват, полный перехват, закрытие сокета — и заканчивается узлом.

Запрос кончился, соединение осталось

Рано или поздно в приложении появляется страница, которая должна что-то показывать сама: уведомление, прогресс длинной задачи, чужой курсор в общем документе. В 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, и один под сам рендер.

Разница ровно в том, где после ответа живёт сокет. Перехват покупает ровно одну вещь: разрыв между «сколько у меня открытых соединений» и «сколько у меня потоков».

ActionController::Live2 потока: Puma ждёт наpop, второй рендеритSSE телом ответа1 поток: Puma внутривашего procActionCable0 потоков: сокет ушёл вцикл событийЗапросСокет остался у сервераСокет забралоприложение
Что соединение стоит в потоках

Миддлвары кончились

Самая тихая часть.

После полного перехвата обработчик возвращается сразу — и вторая половина каждого миддлвара отрабатывает прямо сейчас, пока ваш сокет ещё открыт и разговор ещё не начался. Часть из них ждёт не возврата, а close на теле ответа: Puma закрывает тело в том же ensure, из которого только что вышла с :async, так что разницы никакой.

  • Rack::Deflater никогда не увидит ваших байтов.
  • Логгер напишет строку о завершившемся запросе. Соединение проживёт после этого ещё четыре часа.
  • ActionDispatch::Executor закроет запрос: вернёт соединение ActiveRecord в пул, обнулит CurrentAttributes, отпустит блокировку автозагрузки (эту — только в разработке, в продакшене её никто не брал).
executorзакрыт,соединение ARв пулеGET /cablecall(env)call(env)rack.hijack.callсокет-1, пустойответasyncсообщениечерез часПриложениеКлиентМиддлварыPumaКлиентPumaМиддлварыПриложение
Полный перехват: что происходит по порядку

Последнее и есть ловушка. Код, который пишет в перехваченный сокет, работает уже вне запроса, и всё, что 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

Комментариев пока нет.