How we found a bug in the hyper HTTP library

TL;DR · AI 摘要
Cloudflare 在 hyper HTTP 库中发现了一个间歇性 bug,导致大图像处理失败,最终通过四行代码修复。
核心要点
- Cloudflare 在 hyper HTTP 库中发现了一个间歇性 bug,导致大图像处理失败。
- 该 bug 是一个仅在特定条件下发生的竞态条件,最终通过四行代码修复。
- 图像数据通过 socket 连接在多个服务之间传递,hyper 负责处理响应数据。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- hyper HTTP 库中的竞态条件 bug
- 问题现象
- 图像处理失败,仅在大图像时发生
- 返回 200 状态码但数据不完整
- 调查过程
- 六周时间追踪竞态条件 bug
- 最终通过四行代码修复
- 技术背景
- 图像数据通过 socket 连接传递
- hyper 负责处理响应数据
金句 / Highlights
值得收藏与分享的关键句。
The image data was simply cut short: A response that should have been two megabytes might arrive with a few hundred kilobytes instead.
We spent six weeks chasing a nearly invisible bug â a race condition that occurred only under specific conditions â in the hyper library.
In the end, it took four lines of code to fix it.
我们是如何在 hyper HTTP 库中发现一个 bug 的
2026-06-22
- Deanna Lam
- Diretnan Domnan
- Matt Lewis
12 分钟阅读
Images 服务使用 Rust 在 Workers 上构建,运行在 Cloudflare 的边缘网络中的每一台机器上。为了处理客户端连接,我们使用 hyper,这是一个用于 Rust 的开源 HTTP 库。
去年,我们引入了 Images 绑定,以启用在 Workers 中处理远程图像的自定义、程序化工作流程。到 2025 年底,我们重新设计了该绑定,以在 Workers 运行时和 Images 服务之间提供更直接、本地的连接。
在发布后不久,我们收到了报告称绑定的转换请求失败 —— 但只是偶尔发生,并且只针对较大的图像。更奇怪的是,这些请求的响应返回了 200 状态码,而没有任何错误日志。图像数据只是被截断了:一个本应是两兆字节的响应可能只收到几百千字节。
我们花了六周时间追踪一个几乎看不见的 bug —— 一个只在特定条件下发生的竞态条件,影响了 Images 绑定如何将处理后的图像数据返回给客户端。最终,我们只用四行代码就解决了这个问题。
Hops、handoffs 和 hyper
当开发者在 Cloudflare 上构建应用时,他们通过一组平台服务构建完整的堆栈应用,这些服务可以通过绑定访问到 Workers。绑定为资源提供了直接的 API,这些资源包括 compute、storage、AI inference 和 media processing。
Images 绑定将图像优化与传输解耦;你可以转码、合成或操作图像,而无需将输出作为 HTTP 响应返回。它还允许你以任何顺序应用优化参数,而不是遵循 URL 接口所施加的固定顺序。在这里,一个 worker 可以直接将图像数据传递给 Images API,链接操作,然后以流的形式获取处理后的结果:
const result = await env.IMAGES
.input(image)
.transform({ width: 800, rotate: 90 })
.output({ format: "image/avif" });
return result.response();从高层次来看,图像数据是如何通过我们的各种服务移动的:
管道代表了中介和 Images 之间的套接字连接,数据通过内核的缓冲区从一个进程传递到下一个进程。
绑定通过由 Workers 运行时管理的套接字连接与 Images 通信。套接字连接是两个进程之间的通信通道。套接字的每一端都有由操作系统内核管理的缓冲区;这些缓冲区是临时存储区域,数据在其中一侧写入后,另一侧读取之前会暂时存储。
Hyper 在 Images 服务端管理连接,从套接字读取传入的请求,并将其响应写回套接字。
当请求使用 Images 绑定时,Images 服务读取输入,执行请求的优化操作,并对结果进行编码。然后,它将整个编码后的图像作为一块内存中的数据块传递给 hyper。
Hyper 将此响应数据写入其内部缓冲区。此时,hyper 认为编码工作已经完成,因为它已经拥有了发送所需的所有字节。下一步是将内部缓冲区刷新到套接字的传出缓冲区,将数据从 Images 服务传输到另一端的中介。
如果接收端的读者处理速度很快,那么 hyper 可以在一次传输中将所有数据刷新出去 —— 出站缓冲区会有空间,因为读者会以数据到达的速度立即消费数据。一旦所有数据发送完毕,hyper 会在套接字上发出关闭信号,表示连接已经结束,不会再有数据被写入。但如果接收端的处理速度较慢(即使只是慢了几毫秒),那么出站缓冲区就会被填满,hyper 就需要等待直到有空间才能继续写入。
本地处理
Cloudflare 网络上所有进入的流量都会经过 FL,这是一个内部的中介服务,运行安全和性能功能,并将请求路由到相应的后端。在我们首次推出绑定功能时,图像数据从 Workers 运行时经过 FL,传输到 Images 服务。
这条路径非常适合我们最初的发布,并遵循与我们 URL 接口相同的架构。然而,随着时间的推移,这种与 FL 的耦合变得成为一个限制:每次对绑定的更改都必须遵循 FL 的发布周期。
2025 年 12 月,Images 团队用一个新的中介服务替换了 FL,这是一个运行在相同机器上的内部 worker 绑定。在原始架构中,数据通过 FL 的网络套接字传输;这条路径携带了 FL 完整处理流程的开销,如 DNS 查询和路由。
内部绑定用 Unix 套接字替换了这些功能,直接在同一台机器上的服务之间建立连接,绕过了 FL 和网络堆栈的开销。这使得访问 Images 的请求路径更快,并且给了团队对绑定发布独立的控制权。
在发布后的几天内,我们收到了第一个客户报告。
200 OK(并非 OK)
第一个出现问题的迹象来自一个非标准的客户设置:两层图像处理,其中一条流水线嵌套在另一条流水线中。
首先,他们的 worker 使用 Images 绑定将多个大尺寸的源图像从 R2(一个 JPEG 背景加上 PNG 覆盖层)合成到一个单一的组合 JPEG 中。其次,他们进一步压缩、转码并调整了结果的大小,通过 URL 接口进行处理。
这个错误起源于内部流水线的返回路径,响应在到达外部流水线之前被截断了。
内部流水线(转换绑定)负责合成。外部流水线(转换 URL)负责优化交付,如缩放和格式转换。这种分层的方法意味着,当内部流水线静默地返回一个被截断的响应时,唯一可见的错误出现在上一层:
从连接中读取正文时出错:在达到消息长度之前文件已结束外部流水线从内部流水线接收到 HTTP 200 响应,其中 Content-Length 头部承诺了数兆字节的数据。实际的正文只是其中的一小部分:在一个请求中,只有约 200 KB 的数据到达,而预期是 3.3 MB。错误出现在外部流水线,但截断可能发生在绑定、中介服务、Images 服务或两者之间的某个位置。
当浏览器接收到被截断的图像时,结果是可见的。根据格式的不同,图像要么部分渲染(例如,下半部分缺失或为灰色),要么完全无法解码,从而显示一个损坏的图像。
从这里开始,我们沿着请求路径向内逐步排查,对每一层进行测试,以确定截断发生在哪个环节。这些努力中有些走上了死胡同;有些则留下了线索,使我们缩小了搜索范围:
- 构建复现环境。我们构建了一个模拟客户嵌套设置的工作者,然后逐步剥离各层,直到仅通过绑定即可触发该错误。一个小型脚本使我们能够批量发送请求。在早期的一次运行中,25个请求中有19个失败。到达的数据量大约为200 KB,这个数字与生产环境中的套接字缓冲区大小非常接近。这确认了问题与客户的配置无关,并为我们提供了一种可靠的方法,可以在需要时按需触发该错误。
- 调查超时问题。早期我们怀疑截断可能与超时行为有关(即连接在达到时间限制后被关闭)。但这一假设不成立,因为截断与请求持续时间没有相关性。
- 更新 hyper 版本。当该错误首次报告时,我们使用的是 0.14.x 版本,而最新的 hyper 版本是 1.8.x。我们测试了 0.14、1.7 和 1.8 版本,以防最明显的原因就是正确的(也是最简单的)答案。但该错误在每个版本中都出现,这意味着上游没有修复。
- 本地复现。我们在 macOS 和 Debian 虚拟机上运行了本地集成测试。即使在负载非常大的情况下,我们的本地请求也从未触发任何失败。直接向绑定套接字发送 curl 请求或重放捕获的请求似乎总是有效。该错误只在完整的生产路径上出现,当存在真实的并发和真实的 Workers 运行时客户端在套接字另一端时才会出现。这使我们怀疑是运行时本身的问题。
- 排除 Workers 运行时。我们检查了 Workers 运行时用于通过绑定套接字与 Images 通信的 HTTP 客户端。连接的两端都没有任何系统调用表明有意外关闭或提前终止的情况。我们观察到客户端行为正确,其他多个服务也使用相同的客户端且没有问题。
- 分布式追踪。通过端到端检查请求追踪,我们确认截断的请求体在到达客户设置中的外层转换层之前就已经存在了。这将问题范围缩小到内层管道——即通过 Images 服务的绑定路径。
- 对中间服务进行监控。我们在中间服务中添加了监控,以在转发响应数据之前测量请求体的大小。请求体在离开 Images 服务时就已经被截断,因此中间服务被排除。
- 在 Images 服务内部进行更深入的追踪。在服务级别,请求被正确处理,图像被正确编码,响应以 HTTP 200 发送。
唯一一致的信号是该错误与时间有关:它只在生产路径上出现,只有在存在真实并发和较大的图像时才会发生。
一个真相的种子
应用级调试工具只能告诉我们系统认为它在做什么。但根据系统,一切正常:追踪显示响应已被发送;日志中没有错误报告,且 Images 服务在每次请求中都返回了 200。
为了了解系统实际在做什么,我们将 strace 工具附加到了 Images 服务上。strace 可以记录进程向内核发出的系统调用,这能让我们清楚地看到哪些字节被写入了,何时调用了关闭操作,以及客户端是否发送了任何终止信号。
设置跟踪过程非常精细。strace 通过拦截系统调用的执行过程来工作,这会为每个系统调用增加少量的时间开销。对一组狭窄的系统调用进行过滤,可以将这种开销降到最低。然而,如果扩大过滤范围,就会让进程变慢到足以改变刷新和关闭检查之间的时序,甚至让这个错误完全消失。这一点进一步验证了我们的理论,即问题与时间相关。
我们使用一个重现工作器触发了这个错误,并比较了成功请求和失败请求的系统调用输出。
在成功的请求中,响应会以块的形式写入,只要套接字缓冲区允许,关闭操作只在所有数据发送完毕后才被调用。例如,这可能看起来像这样:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
// ... 继续写入直到缓冲区耗尽 ...
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在这里,只进行了一次写入操作——仅仅足够发送头部和一点点正文数据,之后立即调用了关闭操作。在 14.9 MB 的响应中,只有大约 219 KB 的数据被发送。其余的约 14.8 MB 图像数据从未离开 hyper 的内部缓冲区,也没有客户端在写入和关闭之间发送任何终止信号。相反,Images 服务自行提前关闭了连接,真诚地认为它已经完成了。
失败的请求确认了这个错误是一个间歇性触发的竞态条件。请求的成功与否取决于刷新和关闭操作是否重叠,而这一点在每次请求之间都会发生变化。当缓冲区在 hyper 决定连接已经完成的精确时刻仍然满载时,数据就会丢失。
当读取器的读取速度慢于 hyper 的写入速度时,出站缓冲区就会被填满。如果 hyper 在缓冲区排空之前关闭了连接,那么只有响应的一部分会传送到中间层;这部分不完整的数据会被转发回 Workers 运行时和客户端。
12 月的重构并没有引入这个错误,这个错误在 hyper 的多个主要版本中已经存在多年。但新的中间层改变了响应端套接字的读取者。我们的工作假设是,之前的中间层 FL 读取数据的速度足够快,以至于在响应期间套接字缓冲区很少会被填满。新的读取器以一种偶尔在大响应期间允许缓冲区填满的速度进行读取。
这些由一个让其他一切变快的改进引入的几毫秒的反压,就足以揭示一个一直隐藏在明处的缺陷。
在调度循环内部
Hyper 的 HTTP/1 连接生命周期由一个名为 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,这表示刷新尚未完成。刷新可能仍然有数兆字节的数据停留在缓冲区中,但循环永远无法得知。
当请求失败时,事件的准确顺序如下:
- 图像服务完成图像编码,并将整个响应作为一块内存块一次性交给 hyper。
- Hyper 将该块写入其内部缓冲区,并将其写状态标记为
Writing::Closed。从编码的角度来看,工作已经完成 —— 没有剩余的内容需要编码。
- Hyper 调用
poll_flush将缓冲的数据发送到套接字。在我们之前的例子中,套接字只接受大约 219 KB 的数据。剩余的约 14.8 MB 仍然留在 hyper 的缓冲区中。套接字已满,因此内核返回Poll::Pending。
poll_loop使用let _丢弃了Poll::Pending。
- 它检查
wants_read_again()。请求已经完整接收,因此返回false。
poll_loop返回Poll::Ready(Ok(())),表示循环已经完成,即使刷新尚未完成。
poll_shutdown()被触发。执行了SHUT_WR系统调用。
- 客户端接收到 219 KB 的数据和一个 EOF(文件结束),表示连接已关闭,尽管它期望接收 14.9 MB 的数据。
在第二步中,hyper 在响应体被缓冲(即编码完成)时就将写操作标记为完成,而不是在数据实际刷新到套接字时才标记。大多数情况下,刷新在一次传递中就完成,这种区别是不可见的。在套接字缓冲区满的罕见情况下,刷新必须等待 —— 尽管 hyper 没有等待。这些字节仍然停留在 hyper 的缓冲区中,等待刷新到套接字。Hyper 在这些数据仍然留在缓冲区中的情况下继续关闭连接。
这也解释了为什么 curl 从未触发这个错误。curl 会尽可能快地读取数据:套接字缓冲区永远不会满,刷新总是立即完成,丢弃的返回值是无害的。生产环境路径中,读者偶尔暂停几毫秒的配置,是唯一一个缓冲区在恰好错误的时刻被填满的配置。
别忘了刷新
经过数周的调查,修复本身在概念上是简单的。hyper 需要在继续之前检查刷新是否实际完成。
我们的重现工具确认了这个错误的存在,但它无法告诉我们某个请求为何失败。在编写修复之前,我们需要一个能够触发 hyper 中精确套接字条件的测试。
我们知道触发错误的条件:一个套接字接受一块数据后阻塞。为了在受控场景中进行测试,我们围绕 TCP 流构建了一个自定义包装器,模拟了一个满的套接字缓冲区。该包装器在第一次写入时接受 8 KB 的数据,然后在后续所有写入中返回 Poll::Pending,模拟了一个停止从缓冲区读取数据的读者。
测试通过这个受限的套接字发送了一个 500 KB 的响应,并检查了当仍有 492 KB 数据在缓冲区中时,hyper 是否调用了 shutdown。在修复之前,它确实调用了。修复之后,它会等待。
最初,我们在 hyper 的 dispatch 循环中应用了这个修复。我们不再丢弃 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 返回给异步运行时。运行时会等待套接字变为可写状态,然后唤醒任务继续执行 flush。只有在所有数据发送完毕后,连接才会关闭。
当我们部署这个修复后,我们观察到每个字节都被写入,并且 shutdown 仅在缓冲区实际为空时才被调用。最初报告该问题的客户也确认问题已经消失。
虽然我们的初步解决方案有效,但 dispatch 循环并不是放置修复的正确位置。提前返回 Poll::Pending 可能会减慢同一连接上的其他操作,因为这会减少读取的轮询频率,从而导致意外的背压。此外,它也无法正确处理 keepalive 连接,这种连接会依次处理多个请求,即使前一个响应仍在刷新,这些连接也应保持可重用。这两个问题都没有影响到我们特定的服务(keepalive 在我们的服务中是禁用的),但如果该修复被提交到上游,这两个问题可能会影响其他 hyper 用户。
我们追踪了 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 循环。它只在数据丢失可能发生的确切时刻(关闭之前)添加了刷新操作。
我们学到的经验
应用层的工具没有发现任何错误、崩溃或提供有用线索的日志条目。应用层的可观测性对于那些位于其感知范围之外的错误可能存在盲点。
故障是间歇性发生的,随着响应大小而变化,无法通过 curl 等简单工具复现,并且在我们更仔细观察系统时消失。这些信号指向了连接层中的一个依赖于时间的错误,而不是应用逻辑中的错误。
我们的突破来自于使用内核级工具 strace,这是唯一能记录套接字上实际发生情况的层。底层的错误发生在部分刷新和过早关闭之间的几毫秒内——这个窗口只有在我们让系统变快之后才出现。
我们通过 PR #4018 将我们的修复和确定性测试合并到了 hyperium/hyper 中。它将在未来的 hyper 版本中发布,确保任何使用 hyper 的 HTTP/1 实现的服务都不会因同样的竞态条件而丢失响应数据。
与此同时,我们正在运行一个内部分支,并已应用了该补丁。此修复稳定了绑定的架构,为扩展其功能奠定了可靠的基础。
Images 绑定最初仅涵盖远程图像的转换。本月早些时候,我们宣布 Images 绑定现在支持对托管图像的操作,为开发者提供了一种统一的方式来在 Cloudflare 上构建内容丰富的应用程序。
有关绑定如何工作的更多信息,请参阅我们的文档。
[if astro]>server-island-start<![endif]
图像优化
Cloudflare 图像
开发者
开发者平台
Cloudflare Workers
开源