Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Как мы нашли баг в HTTP-библиотеке hyper

Дата публикации: 25-06-2026 18:29:00

Перестраивая Images binding, команда Cloudflare случайно обнаружила баг в открытой библиотеке hyper, который существовал сразу в нескольких мажорных версиях.— Читать дальше «Как мы нашли баг в HTTP-библиотеке hyper»

Основное содержимое страницы с новостью.

Перевод статьи Дины Лам (Deanna Lam), Диретнана Домнана (Diretnan Domnan) и Мэтта Льюиса (Matt Lewis) из Cloudflare Blog, оригинал: https://blog.cloudflare.com/hyper-bug/

Сервис Images, написанный на Rust и работающий в Workers, запущен на каждой машине в edge-сети Cloudflare. Чтобы обрабатывать клиентские соединения, мы используем hyper — открытую HTTP-библиотеку для Rust.

В прошлом году мы представили Images binding — он позволяет создавать кастомные программные сценарии обработки удалённых изображений в Workers. В конце 2025 года мы перестроили binding, чтобы сделать связь между Workers runtime и сервисом Images более прямой и локальной.

Вскоре после выкатки мы получили сообщения о том, что запросы на трансформацию из binding падают — но только иногда и только для больших изображений. Ещё страннее было то, что ответы на эти запросы возвращали статус 200, и в логах не было никаких ошибок. Данные изображения просто обрывались: ответ, который должен был весить два мегабайта, мог прийти всего в несколько сотен килобайт.

Мы потратили шесть недель на охоту за почти невидимым багом — состоянием гонки, которое проявлялось только при определённых условиях, — в библиотеке hyper, который влиял на то, как Images binding возвращает обработанные изображения клиенту. В итоге исправление заняло четыре строки кода.

Cloudflare Images binding перешёл на прямое локальное соединение через Unix-сокеты в декабре 2025 года.

После выкатки большие изображения стали иногда возвращаться обрезанными: HTTP 200, но тело ответа короче Content-Length.

Причина — состояние гонки в hyper: цикл poll_loop отбрасывал результат poll_flush, даже если тот возвращал Poll::Pending.

Баг существовал в hyper 0.14.x, 1.7 и 1.8; прежний посредник FL читал данные достаточно быстро, чтобы он не проявлялся.

Исправление заняло четыре строки кода: flush теперь выполняется перед shutdown.

Когда разработчики создают приложения на Cloudflare, они собирают full-stack приложения из набора платформенных сервисов, доступных Workers через bindings. Bindings предоставляют прямые API к ресурсам Developer Platform: вычислениям, хранилищам, ИИ-инференсу и обработке медиа.

Images binding отделяет оптимизацию изображений от доставки: вы можете транскодировать, компоновать или изменять изображения, не возвращая результат в виде HTTP-ответа. Также он позволяет применять параметры оптимизации в любом порядке, а не в фиксированной последовательности, которую навязывает URL-интерфейс. Вот пример: воркер передаёт данные изображения напрямую в Images API, объединяет операции в цепочку и получает обработанный результат в виде потока:

			const result = await env.IMAGES
  .input(image)
  .transform({ width: 800, rotate: 90 })
  .output({ format: "image/avif" });
return result.response();
		

На высоком уровне так выглядит движение данных через наши сервисы:

Схема движения данных между Workers runtime, посредником и сервисом Images

Схема движения данных между Workers runtime, посредником и сервисом Images при использовании Images binding

Труба на схеме обозначает сокетное соединение между посредником и Images, через которое данные передаются от одного процесса к другому через буфер ядра.

Binding взаимодействует с Images через сокетное соединение, управляемое Workers runtime. Сокет — это канал связи между двумя процессами. У каждого конца сокета есть буферы, управляемые ядром операционной системы; эти буферы — временные области, где данные остаются после записи одной стороной, но до чтения другой.

Hyper управляет соединением на стороне сервиса Images: читает входящие запросы из сокета и пишет ответы обратно в него.

Когда запрос использует Images binding, сервис Images читает входные данные, выполняет запрошенные операции оптимизации и кодирует результат. Затем он передаёт всё закодированное изображение hyper как один непрерывный блок в памяти.

Hyper записывает эти данные ответа в свой внутренний буфер. На этом моменте hyper считает кодирование завершённым, потому что у него есть все байты, которые нужно отправить. Следующий шаг — сбросить внутренний буфер в исходящий буфер сокета, переместив данные от сервиса Images к посреднику на другом конце.

Если читатель на другом конце быстрый, hyper может сбросить всё за один проход — в исходящем буфере будет место, потому что читатель потребляет данные по мере поступления. Как только все данные отправлены, hyper вызывает shutdown на сокете, сигнализируя, что соединение завершено и больше данных записано не будет. Но если читатель медленнее (хотя бы на несколько миллисекунд), исходящий буфер заполняется, и hyper должен ждать, пока появится место, чтобы продолжить запись.

Локальное соединение

Весь входящий трафик в сети Cloudflare проходит через FL — внутренний сервис-посредник, который включает функции безопасности и производительности и маршрутизирует запросы к нужному бэкенду. Когда мы только запустили binding, данные изображений шли из Workers runtime через FL в сервис Images.

Этот путь хорошо подходил для первого релиза и повторял архитектуру URL-интерфейса. Но со временем связь с FL стала ограничением: любое изменение binding приходилось согласовывать с циклом релизов FL.

В декабре 2025 года команда Images заменила FL новым посредником — внутренним worker binding, который работает на той же машине. В исходной архитектуре данные шли через FL по сетевым сокетам; этот путь нёс накладные расходы полного конвейера обработки FL, такие как DNS-lookup'ы и маршрутизация.

Внутренний binding заменил их на Unix-сокеты, чтобы напрямую соединить сервисы на одной машине, минуя FL и накладные расходы сетевого стека. Это ускорило путь запроса к Images и дало команде независимый контроль над релизами binding.

В течение нескольких дней после выкатки мы получили первый отчёт от клиента.

200 OK (не OK)

Первый признак проблемы пришёл от клиента с нестандартной конфигурацией: два уровня обработки изображений, где один pipeline был вложен в другой.

Сначала их воркер с помощью Images binding компоновал несколько больших исходных изображений из R2 — JPEG-фон и PNG-оверлеи — в один объединённый JPEG. Затем результат дополнительно сжимался, транскодировался и изменялся размер через URL-интерфейс.

Баг возникал на обратном пути внутреннего pipeline, где ответ обрезался до того, как достигал внешнего.

Внутренний pipeline (transformation binding) отвечал за компоновку. Внешний pipeline (transformation URL) отвечал за оптимизации доставки: масштабирование и конвертацию формата. Такая многоуровневая схема означала, что когда внутренний pipeline молча возвращал обрезанный ответ, видимая ошибка появлялась на уровень выше:

			error reading a body from connection: end of file before message length reached
		

Внешний pipeline получал от внутреннего HTTP 200 с заголовком Content-Length, который обещал несколько мегабайт. Фактическое тело было лишь частью от этого: в одном запросе из ожидаемых 3,3 МБ пришло всего около 200 КБ. Ошибка всплывала во внешнем pipeline, но обрезка могла произойти в binding, посреднике, сервисе Images или где-то между ними.

Когда браузер получает обрезанное изображение, результат виден невооружённым глазом. В зависимости от формата изображение либо отрисовывается частично (например, с отсутствующей или серой нижней частью), либо полностью не декодируется и отображается как битое.

Отладка вслепую

Отсюда мы двигались вглубь по пути запроса, проверяя каждый уровень, чтобы локализовать место обрезки. Некоторые усилия зашли в тупик; другие оставили зацепки, которые сузили поиск:

  • Сборка репродукции. Мы собрали воркер, повторяющий вложенную схему клиента, и постепенно убирали уровни, пока не смогли воспроизвести баг только с binding. Небольшой скрипт отправлял запросы пачками. На одном из первых прогонов 19 из 25 запросов упали. Объём данных, которые всё-таки приходили, — примерно 200 КБ — настораживающе близко к размеру сокетного буфера в продакшене. Это подтвердило, что проблема не связана с конфигурацией клиента, и дало надёжный способ воспроизводить баг по запросу.
  • Проверка таймаутов. Сначала мы подозревали, что обрезка связана с поведением таймаутов (то есть соединение закрывалось по истечении лимита времени). Эта теория не подтвердилась: обрезка не коррелировала с длительностью запроса.
  • Обновление версии hyper. Когда баг впервые зарепортили, мы использовали 0.14.x, а последняя версия hyper была около 1.8.x. Мы протестировали версии 0.14, 1.7 и 1.8 — на случай, если самый очевидный ответ окажется правильным (и самым простым). Но баг проявлялся в каждой версии, значит, upstream-исправления не было.
  • Локальное воспроизведение. Мы запускали интеграционные тесты на macOS и Debian-виртуалке. Даже под значительной нагрузкой локальные запросы никогда не падали. Прямые curl-запросы к сокету binding и повтор отловленных запросов всегда работали. Баг проявлялся только на полном продакшен-пути при реальной конкурентности и реальном клиенте Workers runtime на другом конце сокета. Это натолкнуло нас на подозрение, что виноват сам runtime.
  • Исключение Workers runtime. Мы изучили HTTP-клиент, который Workers runtime использует для связи с Images через сокет binding. Ни на одной из сторон соединения в трассировках не было системных вызовов, указывающих на неожиданное закрытие или преждевременное завершение. Клиент вёл себя корректно, и несколько других сервисов использовали того же клиента без проблем.
  • Распределённая трассировка. Изучив сквозные трассировки запросов, мы подтвердили, что обрезанное тело уже присутствовало до того, как достигало внешнего уровня трансформации в схеме клиента. Это сузило проблему до внутреннего pipeline — пути binding через сервис Images.
  • Инструментирование посредника. Мы добавили инструментирование в посредник, чтобы измерять размеры тел перед пересылкой ответа. Тела уже были обрезаны к моменту выхода из сервиса Images, так что посредник был исключён.
  • Углублённая трассировка внутри Images. На уровне сервиса запрос обрабатывался, изображение корректно кодировалось, и ответ отправлялся с HTTP 200.

Единственный постоянный сигнал заключался в том, что баг зависел от тайминга: он появлялся только на продакшен-пути, при реальной конкурентности и только для больших изображений.

Зерно истины

Инструменты отладки на уровне приложения показывали лишь то, что система считала, что делает. Но по мнению системы всё было в порядке: трассировка утверждала, что ответ отправлен; логи не сообщали об ошибках; сервис Images возвращал 200 на каждый запрос.

Чтобы увидеть, что система делала на самом деле, мы подключили strace к сервису Images. strace записывает системные вызовы, которые процесс делает ядру; это позволило показать, какие именно байты были записаны, когда вызывался shutdown и посылал ли клиент сигнал завершения.

Настройка трассировки была деликатной. strace работает, перехватывая системные вызовы по мере их выполнения, что добавляет небольшие накладные расходы по времени к каждому вызову. Фильтрация узкого набора системных вызовов держала эти накладные расходы минимальными. Однако расширение фильтра замедляло процесс ровно настолько, чтобы сдвинуть тайминг между flush и проверкой shutdown — и баг полностью исчезал. Одно это уже подкрепляло теорию о тайминг-зависимости проблемы.

Используя воркер-репродукцию, мы спровоцировали баг и сравнили вывод системных вызовов между успешными и упавшими запросами.

При успешном запросе ответ пишется кусками по мере освобождения сокетного буфера, а shutdown вызывается только после отправки всех данных. Например, это может выглядеть так:

			sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
// ... keeps writing until buffer drains ...
sendto(42, "...", 292352) = 292352
shutdown(42, SHUT_WR) = 0
		

Когда мы воспроизвели баг, упавший запрос выглядел так:

			sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
shutdown(42, SHUT_WR) = 0
		

Здесь есть только одна запись — лишь заголовки и крошечная часть тела — перед немедленным вызовом shutdown. Из ответа в 14,9 МБ было отправлено около 219 КБ. Оставшиеся ~14,8 МБ данных изображения никогда не покидали внутренний буфер hyper, и не было никакого сигнала завершения от клиента между записью и shutdown. Вместо этого сервис Images преждевременно закрывал соединение самостоятельно, искренне полагая, что работа завершена.

Упавшие запросы подтвердили, что баг — состояние гонки, которое срабатывало непостоянно. Успех или провал зависели от того, перекрывались ли операции flush и shutdown, и это менялось от запроса к запросу. Когда буфер был полон в тот самый момент, когда hyper решил, что соединение завершено, данные терялись.

Когда читатель потребляет медленнее, чем hyper пишет, исходящий буфер заполняется. Если hyper закрывает соединение до того, как буфер опустеет, то лишь часть ответа попадает к посреднику; эти неполные данные пересылаются обратно в Workers runtime и клиенту.

Перестройка в декабре не ввела этот баг — он существовал в hyper годами в нескольких мажорных версиях. Но новый посредник изменил того, кто читал ответ на другом конце сокета. Наша рабочая теория: прежний посредник FL потреблял данные достаточно быстро, чтобы сокетный буфер редко заполнялся во время ответа. Новый читатель работал в темпе, который иногда позволял буферу заполняться при больших ответах.

Этих нескольких миллисекунд обратного давления, внесённых улучшением, которое ускорило всё остальное, хватило, чтобы выявить изъян, который скрывался на виду.

Внутри цикла dispatch

Жизненный цикл HTTP/1-соединения в hyper управляется конечным автоматом в файле под названием dispatch.rs. Он выполняет цикл, который читает запросы, пишет ответы, сбрасывает буфер записи в сокет и решает, когда закрываться. В упрощённом виде:

			fn poll_loop(&mut self, cx: &mut Context'_>) -> Poll<Result<(), Error>> {
    loop {
        let _ = self.poll_read(cx)?;
        let _ = self.poll_write(cx)?;
        let _ = self.poll_flush(cx)?;

        if !self.conn.wants_read_again() {
            return Poll::Ready(Ok(()));
        }
    }
}
		

Точнее, именно let _ перед poll_flush — это место, где живёт баг.

В Rust let _ = expr отбрасывает результат выражения, включая Poll::Pending — сигнал о том, что flush ещё не завершён. В буфере flush может остаться несколько мегабайт, но цикл об этом никогда не узнаёт.

Когда запрос падает, последовательность событий выглядит так:

  1. Сервис Images завершает кодирование изображения и передаёт весь ответ hyper как один блок в памяти.
  2. Hyper записывает блок во внутренний буфер и помечает состояние записи как Writing::Closed. С точки зрения кодирования работа сделана — кодировать больше нечего.
  3. Hyper вызывает poll_flush, чтобы перенести буферизованные данные в сокет. В нашем примере сокет принял около 219 КБ. Оставшиеся ~14,8 МБ остаются в буфере hyper. Сокет полон, поэтому ядро возвращает Poll::Pending.
  4. poll_loop отбрасывает Poll::Pending с помощью let _.
  5. Он проверяет wants_read_again(). Полный запрос уже получен, поэтому возвращается false.
  6. poll_loop возвращает Poll::Ready(Ok(())), сигнализируя, что цикл завершён, хотя flush ещё не сделан.
  7. Срабатывает poll_shutdown(). Выполняется системный вызов SHUT_WR.
  8. Клиент получает 219 КБ и EOF (end-of-file), указывающий, что соединение закрыто, хотя он ожидает 14,9 МБ.

На втором шаге hyper помечает операцию записи как завершённую, как только тело ответа оказывается в буфере (то есть когда кодирование закончено), а не когда данные фактически сброшены. В большинстве случаев flush завершается за один проход, и это различие незаметно. В редких случаях, когда сокетный буфер полон, flush приходится ждать — но hyper не ждёт. Байты всё ещё сидят в буфере hyper, ожидая сброса в сокет. Hyper при этом закрывает соединение с этими данными всё ещё в буфере.

Это также объясняет, почему curl никогда не воспроизводил баг. Curl читает данные так быстро, как они приходят: сокетный буфер никогда не заполняется, flush всегда завершается мгновенно, и отброшенное возвращаемое значение безобидно. Продакшен-путь с читателем, который иногда паузил на несколько миллисекунд, был единственной конфигурацией, где буфер заполнялся в нужный момент.

Не забывайте сбрасывать буфер

После недель расследования само исправление было концептуально простым. Hyper должен был проверять, завершён ли flush, прежде чем двигаться дальше.

Наш reproduction-воркер подтвердил, что баг существует, но не мог объяснить, почему падает конкретный запрос. Прежде чем писать исправление, нам нужен был тест, который мог бы спровоцировать точные сокетные условия внутри hyper.

Мы знали условия, вызывающие баг: сокет, который принимает один кусок данных, а затем блокируется. Для контролируемого сценария мы построили обёртку вокруг TCP-потока, имитирующую полный сокетный буфер. Обёртка принимала 8 КБ при первой записи, а затем возвращала Poll::Pending на каждой последующей записи, имитируя читателя, который перестал опустошать буфер.

Тест отправлял 500 КБ ответа через этот ограниченный сокет и проверял, вызывает ли hyper shutdown, пока в буфере остаётся 492 КБ. Без исправления — вызывал. С исправлением — ждал.

Сначала мы применили исправление в цикле dispatch hyper. Вместо отбрасывания результата poll_flush мы проверяли, завершён ли flush на самом деле:

			let flush_result = self.poll_flush(cx)?;

if flush_result.is_pending() {
    return Poll::Pending;
}

if !self.conn.wants_read_again() {
    return Poll::Ready(Ok(()));
}
		

Если flush не завершён, цикл возвращает Poll::Pending асинхронному runtime. Runtime ждёт, пока сокет не станет доступен для записи, а затем будит задачу, чтобы продолжить flush. Соединение закрывается только после того, как все данные отправлены.

Когда мы выкатили это исправление, мы увидели, что записан каждый байт, а shutdown вызывался только после того, как буфер действительно опустел. Клиент, который сделал первый репорт, тоже подтвердил, что проблема исчезла.

Хотя первоначальное решение работало, цикл dispatch был неправильным местом для исправления. Ранний возврат Poll::Pending мог замедлять другие операции на том же соединении, уменьшая частоту опроса чтения и вызывая нежелательное обратное давление. Также это корректно не обрабатывает keepalive-соединения, где одно соединение обрабатывает несколько запросов подряд — они должны оставаться пригодными к использованию, даже пока предыдущий ответ всё ещё сбрасывается. Ни одна из этих проблем не затрагивала наш сервис (где keepalive отключён), но обе могли повлиять на других пользователей hyper, если бы исправление было предложено upstream.

Мы проследили жизненный цикл соединения hyper и нашли более точечный подход. Вместо изменения поведения цикла dispatch мы применили исправление в том месте, где shutdown вызывается на самом деле. Перед закрытием сокета hyper должен сначала сбросить оставшиеся данные в буфере:

			pub(crate) fn poll_shutdown(
    &mut self,
    cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
    ready!(self.poll_flush(cx)?);
    Pin::new(&mut self.io).poll_shutdown(cx)
}
		

Это оставляет цикл dispatch без изменений. Flush добавляется только в тот точный момент, где иначе произошла бы потеря данных — непосредственно перед shutdown.

Что осталось с нами

Ни один из инструментов на уровне приложения не выдавал ошибок, падений или полезных записей в логе. Наблюдаемость на уровне приложения может иметь слепое пятно для багов, которые живут ниже её уровня осознанности.

Сбой происходил непостоянно, масштабировался с размером ответа, не воспроизводился простыми инструментами вроде curl и исчезал, когда мы наблюдали за системой внимательнее. Эти сигналы указывали на тайминг-зависимый баг в слое соединения, а не в логике приложения.

Прорыв случился благодаря инструментарию уровня ядра — strace, единственному слою, который фиксирует, что на самом деле происходило на сокете. Базовый баг жил в нескольких миллисекундах между частичным flush и преждевременным shutdown — окне, которое открылось только после того, как мы ускорили систему.

Мы влили исправление и детерминированный тест в hyperium/hyper через PR #4018. Оно появится в будущем релизе hyper, гарантируя, что любой сервис, использующий HTTP/1-реализацию hyper, не потеряет данные ответа из-за того же состояния гонки.

Пока мы используем внутренний форк с применённым патчем. Это исправление стабилизировало архитектуру binding, создав надёжную основу для расширения его функциональности.

Изначально Images binding покрывал только трансформации удалённых изображений. В начале этого месяца мы объявили, что Images binding теперь поддерживает операции для hosted-изображений, давая разработчикам единый способ строить медиа-насыщенные приложения на Cloudflare.

Подробнее о том, как работает binding, — в нашей документации.

Часто задаваемые вопросы

1

Что такое hyper и почему о нём важно знать?

Hyper — это открытая HTTP-библиотека для Rust, которую Cloudflare использует в сервисе Images для обработки клиентских соединений. Баг затронул её HTTP/1-реализацию и существовал сразу в нескольких мажорных версиях.

2

Почему баг проявлялся только на больших изображениях?

Для больших ответов сокетный буфер мог заполняться до того, как hyper успевал сбросить все данные. При маленьких ответах весь ответ помещался в буфер сразу, и race condition не срабатывал.

3

Почему curl не воспроизводил баг?

Curl читает данные так быстро, как они приходят, поэтому сокетный буфер никогда не заполнялся, flush всегда завершался сразу, и отброшенное значение Poll::Pending было безобидно.

4

Какое исправление внесли в hyper?

Всего четыре строки кода: в poll_shutdown добавили flush перед собственно закрытием сокета. Это гарантирует, что все данные покинут буфер hyper, прежде чем соединение завершится.

5

Когда исправление появится в upstream?

Cloudflare влила патч и тест в hyperium/hyper через PR #4018. Исправление войдёт в будущий релиз hyper; пока команда использует внутренний форк с патчем.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Бесплатный WAF инструмент кибербезопасности, который я использую5801-07-2026
2Почему классический CI/CD не справляется с LLM (и какие release gates мы построили, чтобы это исправить)0704-07-2026
3Как объединить инфраструктуру 35 продуктов в единую платформу данных0726-06-2026
4Как ограничить расходы на OpenAI API, чтобы ИИ-агенты не сожгли бюджет0804-07-2026
5Как выбрать VPS/VDS под свой проект: гайд по параметрам и 6 провайдеров0830-06-2026
6Вайб-кодинг в терминале: настраиваем OpenCode с Claude, GPT и Gemini0530-06-2026
7AI-агенты сломали управление жизненным циклом идентификации. Что с этим делать0704-07-2026
8Зарубежный хостинг для сайта: ТОП-25 иностранных хостинг-провайдеров0526-06-2026
9Кнопка «наверх» в Django: почему в проде это уже не три строки JavaScript0701-07-2026

Классификация: Пресс-релизы. Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 5. Источник: tproger.ru.