@ayushagarwal027: Cloudflare 花了 6 周追踪 hyper Rust HTTP 库中的一个 bug。修复只需 4 行代码。症状:图片响应……
摘要
Cloudflare 工程师花了六周时间调试 hyper Rust HTTP 库中的一个竞态条件,该问题导致大图片响应中出现静默数据截断。根本原因是在 flush 期间,一个 `let _ =` 丢弃了 `Poll::Pending` 信号;修复方式是一处四行代码的修改,现已合并到上游。
查看缓存全文
缓存时间: 2026/06/26 10:09
Cloudflare 花了 6 周时间追踪 hyper Rust HTTP 库中的一个 bug。修复只有 4 行代码。症状:图片响应返回 HTTP 200,但数据被截断——一个 14.9 MB 的响应只收到了 219 KB,且没有任何地方记录错误。根本原因:hyper 的调度循环中一个 let _ = 丢弃了来自刷新操作的 Poll::Pending 信号。套接字缓冲区填满后,刷新返回 pending,但 hyper 忽略了它,仍然调用了关闭操作,静默地丢弃了剩余数据。为什么这么难找到:→ 只在生产环境出现,用 curl 从未发生 → 只有在大图片且真实并发条件下才触发 → 当 strace 范围扩大时消失(轻微减慢速度,改变了时序) → 所有应用层日志都报告成功。突破来自 strace:内核级系统调用追踪,显示关闭操作在一次写入后就被调用,而缓冲区中还有 14.8 MB 数据。这个 bug 存在于 hyper 的多个主要版本中(0.14 到 1.8)。它之所以隐蔽,是因为大多数读取器消耗数据足够快,套接字缓冲区从未填满。一个更快的新中间件引入了刚好足够的背压,才暴露了它。修复现已合并到上游 hyperium/hyper PR #4018。这是一堂关于异步 Rust 系统级调试的典范课程。http://blog.cloudflare.com/hyper-bug #RustLang #AsyncRust #Hyper #SystemsProgramming #Debugging #OpenSource #Cloudflare
我们在 hyper HTTP 库中发现了一个 bug
来源:https://blog.cloudflare.com/hyper-bug/ 2026-06-22 12 分钟阅读
Images 服务(https://developers.cloudflare.com/images/)基于 Rust 构建,运行在 Workers(https://developers.cloudflare.com/workers/)之上,部署在 Cloudflare 边缘网络的每台机器上。为了处理客户端连接,我们使用了 hyper(https://github.com/hyperium/hyper),一个 Rust 的开源 HTTP 库。去年,我们引入了 Images 绑定(https://blog.cloudflare.com/improve-your-media-pipelines-with-the-images-binding-for-cloudflare-workers),以便在 Workers 中实现自定义、可编程的远程图片处理工作流。2025 年底,我们重新设计了绑定架构,使 Workers 运行时与 Images 服务之间建立更直接、更本地的连接。上线后不久,我们收到报告,称绑定中的转换请求偶尔会失败——但仅对较大的图片间歇性发生。更奇怪的是,这些请求的响应返回了 200 状态,且没有任何错误日志。图片数据被简单地截断了:本应是两兆字节的响应,只收到了几百千字节。
我们花了六周时间追踪一个几乎不可见的 bug——一个仅在特定条件下出现的竞态条件——它位于 hyper 库中,影响了 Images 绑定将处理后的图片数据返回给客户端的方式。最终,修复只需要四行代码。
跳转、交接与 hyper
当开发者在 Cloudflare 上构建时,他们会将全栈应用组合为一组平台服务,这些服务通过绑定暴露给 Workers。绑定(https://developers.cloudflare.com/workers/runtime-apis/bindings/)为开发者平台上的资源提供直接 API,例如计算(https://www.cloudflare.com/products/#compute)、存储(https://www.cloudflare.com/products/#storage)、AI 推理(https://www.cloudflare.com/products/#ai)和媒体处理(https://www.cloudflare.com/products/#media)。
Images 绑定将图片优化与交付解耦;你可以转码、合成或操作图片,而无需将输出作为 HTTP 响应返回。它还允许以任意顺序应用优化参数(https://developers.cloudflare.com/images/optimization/features/),而不必遵循 URL 接口(https://developers.cloudflare.com/images/optimization/features/#url-interface)所强制的固定顺序。
下面是一个 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 会在套接字上发出 shutdown 信号,表示连接完成,不再写入数据。但如果读取器较慢(哪怕只慢几毫秒),那么出站缓冲区会填满,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 接口进一步压缩、转码和调整结果的大小。
bug 产生于内部管道的返回路径,响应在到达外部管道之前被截断。
内部管道(转换绑定)负责合成。外部管道(转换 URL)负责交付优化,如缩放和格式转换。这种分层方法意味着,当内部管道静默返回一个截断的响应时,唯一的可见错误出现在上一级:
error reading a body from connection: end of file before message length reached
外部管道从内部管道收到 HTTP 200,带有一个 Content-Length 头部,承诺了几兆字节。实际主体只有其中的一小部分:在一个请求中,预期的 3.3 MB 只收到了约 200 KB。错误出现在外部管道中,但截断可能发生在绑定、中间件服务、Images 服务或它们之间的某个环节。
当浏览器收到截断的图片时,结果是可见的。根据格式不同,图片要么部分渲染(例如,底部一半缺失或变灰),要么完全解码失败,显示为损坏的图片。
在黑暗中调试
从此,我们沿着请求路径向内排查,测试每一层以确定截断发生的位置。这些努力中有些走进了死胡同,有些则留下了线索,缩小了搜索范围:
-
构建复现环境。 我们构建了一个模仿客户嵌套设置的 worker,然后逐层剥离,直到仅凭绑定就能触发 bug。一个小脚本让我们可以批量发送请求。在一次早期运行中,25 个请求中有 19 个失败。实际到达的数据量——大约 200 KB——与生产环境中套接字缓冲区的大小惊人地接近。这证实了问题并非与客户配置相关,并给了我们一个按需可靠触发 bug 的方法。
-
调查超时。 早期我们怀疑截断可能与超时行为有关(即连接在达到时间限制后被关闭)。这个理论没有站住脚,因为截断与请求持续时间不相关。
-
更新 hyper 版本。 当 bug 首次报告时,我们运行的是 0.14.x,而最新的 hyper 版本大约是 1.8.x。我们在 hyper 版本 0.14、1.7 和 1.8 上进行了测试,以防最明显的答案就是正确的(也是最简单的)。但 bug 在每个版本中都出现了,这意味着上游并没有修复。
-
本地复现。 我们在 macOS 和 Debian 虚拟机上运行了本地集成测试。即使在相当大的负载下,我们的本地请求也从未触发任何失败。直接使用 curl 请求绑定套接字以及重放捕获的请求,似乎总是正常工作。bug 只在完整的生产路径上出现,当存在真实并发且套接字另一端有真正的 Workers 运行时客户端时。这使我们开始怀疑运行时本身。
-
排除 Workers 运行时。 我们检查了 Workers 运行时用于通过绑定套接字与 Images 通信的 HTTP 客户端。连接两端的任何追踪都未显示指示意外关闭或提前终止的系统调用。我们观察到客户端行为正确,并且其他多个服务也使用相同的客户端而没有出现问题。
-
分布式追踪。 通过检查端到端的请求追踪,我们确认截断后的主体在到达客户设置中的外层转换层之前就已经存在。这缩小了问题范围到内部管道——通过 Images 服务的绑定路径。
-
对中间件服务进行仪表化。 我们在中间件服务中添加了仪表化,以在转发响应数据之前测量主体大小。主体在离开 Images 服务时已经被截断,因此中间件被排除了。
-
在 Images 服务内部进行更深入的追踪。 在服务层面,请求被处理,图片被正确编码,响应以 HTTP
200发送。唯一一致的信号是 bug 依赖于时机:它只出现在生产路径上,具有真实并发,并且只针对较大的图片。
内核的真相
应用级调试工具只告诉我们系统认为自己在做什么。但根据系统,一切正常:追踪说响应已发送;日志没有报告错误;Images 服务对每个请求都返回了 200。为了查看系统实际在做什么,我们在 Images 服务上附加了 strace。strace 记录进程向内核发出的系统调用,这可以精确显示哪些字节被写入、何时调用了关闭、以及客户端是否发送了任何终止信号。
设置追踪需要非常小心。strace 通过拦截系统调用来工作,这会给每个调用增加少量时序开销。将过滤范围缩小到一组狭窄的系统调用可以将开销降到最低。然而,扩大过滤范围会稍微减慢进程,足以改变刷新与关闭检查之间的时序——并使 bug 完全消失。这本身就强化了我们的理论:问题对时序敏感。
使用复现 worker,我们触发了 bug 并比较了成功与失败请求的系统调用输出。在成功请求中,响应会根据套接字缓冲区允许的情况分块写入,只有在所有数据发送完毕后才会调用关闭。例如,可能看起来像这样:
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
当我们复现 bug 时,一个失败请求看起来像这样:
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 服务过早地自行关闭了连接,并且它确实认为已经完成了。
失败的请求证实了 bug 是一个间歇性触发的竞态条件。请求是否成功取决于刷新和关闭操作是否重叠,而这因请求而异。当缓冲区在 hyper 决定连接完成的那个确切时刻仍然满时,数据就会丢失。
当读取器消费速度慢于 hyper 写入速度时,出站缓冲区会填满。如果 hyper 在缓冲区排空之前就关闭了连接,那么只有一小部分响应能到达中间件;这个不完整的数据会被转发回 Workers 运行时和客户端。
12 月的重新架构并没有引入这个 bug,它已经在 hyper 中存在多年,跨越了多个主要版本。但是新的中间件改变了在套接字响应一侧的读取者。我们的工作理论是,之前的中间件 FL 消耗数据足够快,以至于在响应期间套接字缓冲区很少填满。新的读取器以一定的节奏读取,偶尔在较大响应期间会让缓冲区填满。这几毫秒的背压,由一个让其他一切都变得更快了的改进引入,足以暴露一个一直公开隐藏的缺陷。
调度循环内部
Hyper 的 HTTP/1 连接生命周期由一个状态机驱动,它位于名为 dispatch.rs 的文件中。它运行一个循环,读取请求,写入响应,将写缓冲区刷新到套接字,并决定何时关闭。简化形式如下:
fn poll_loop(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
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 之前,这正是 bug 所在。在 Rust 中,let _ = expr 丢弃了表达式的值。如果 poll_flush 返回 Poll::Pending——表示“还没有准备好,稍后再试”——那么 let _ 就会丢弃这个信号,循环会继续执行到关闭检查。
相似文章
我们如何在 hyper HTTP 库中发现了一个 bug
Cloudflare 工程师在调试其 Images 服务间歇性故障时,发现 hyper HTTP 库中存在一个竞态条件 bug,该故障导致大图像转换返回截断的响应。修复只需要四行代码。
当“空闲”并不空闲:Linux 内核优化如何引发 QUIC 缺陷
Cloudflare 详细描述了其 QUIC 实现 quiche 中的一个缺陷,该缺陷由 Linux 内核针对 CUBIC 拥塞控制的优化引发,并导致了性能问题,同时介绍了相应的修复方案。
核心转储流行病学:修复一个18年的旧bug
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
AI与密码学的邂逅1:AI在Cloudflare的CIRCL中发现了什么
使用AI审计代理,zkSecurity在Cloudflare的CIRCL密码学库中发现了七个真实漏洞,包括严重的精度损失和访问控制破坏。所有漏洞已在上游修复。
一个不稳定的测试暴露了 Redis 客户端的 use-after-free 漏洞
一篇来自 Buildkite 的工程博客文章详细描述了一个不稳定的测试如何导致在 redis-client Ruby 库中发现了一个 use-after-free 漏洞,并介绍了调试过程和根本原因分析。