Cloudflare Images · hyper HTTP/1 · Rust

一个 200 OK 背后的截断响应

Cloudflare 这篇文章讲的是一次很典型的生产级竞态排查:图片转换请求返回成功状态码, 但大图响应体只发出一小段。最终根因不是业务代码,而是 hyper HTTP/1 在 socket 还没 flush 完时过早 shutdown。

  • 原文:How we found a bug in the hyper HTTP library
  • 日期:2026-06-22
  • 作者:Deanna Lam、Diretnan Domnan、Matt Lewis
  • 阅读时间:12 分钟

故障的本质:写缓冲还没倒空,连接先被关了

Images Service 完成图片编码,把完整响应交给 hyper;业务层认为已经成功。
socket outbound buffer 读端稍慢时,内核缓冲区会满;此时剩余数据仍停在 hyper 内部缓冲。
实际写出约 219 KB 应写出约 14.9 MB
表象:HTTP 状态码是 200,日志里没有业务错误。
真实:客户端按 Content-Length 等待更多字节,却提前收到 EOF。

一页总结

Cloudflare Images binding 在 2025 年底改造为更直接的本机路径后,某些大图转换请求开始间歇性失败: 内层图片处理返回 200,但响应体被截断。经过六周排查,团队发现问题藏在 Rust HTTP 库 hyper 的 HTTP/1 连接生命周期里:当 socket 写缓冲因为读端稍慢而满了,hyper 的 flush 返回 Pending,但调度循环没有保留这个信号,随后连接被 shutdown,导致剩余数据永远没写出去。

问题

成功状态掩盖了数据丢失

服务端完成图片编码并返回成功;客户端却只收到响应体的一部分,外层转换再报 EOF 或解码失败。

触发条件

大响应 + 瞬时背压

只有当响应较大、读端略慢、socket 缓冲区被填满时,flush 才会挂起;简单 curl 往往复现不了。

修复

shutdown 前强制确认 flush

最终补丁在执行 socket shutdown 前先 poll flush,确保内部缓冲区剩余字节全部送入底层 IO。

为什么架构调整会暴露它

Images binding 让 Workers 能以 API 的方式调用 Cloudflare Images 做图片转换、合成、转码等操作。 最初绑定路径沿用 Cloudflare 的内部流量入口 FL;后来团队把路径改成同机内部 worker binding,通过 Unix socket 直接连到 Images 服务,以减少 FL 处理链路带来的开销,并让 binding 发布节奏不再受 FL 约束。

旧路径:经过 FL

Workers runtime FL Images service

FL 读响应很快,socket 写缓冲很少被填满;hyper 里已有的问题长期被隐藏。

新路径:同机直连

Workers runtime internal binding Unix socket Images service

路径整体更直接,但新的读端节奏偶尔让 socket 出现几毫秒背压,正好撞上 hyper 的竞态窗口。

重点:新架构并没有“制造”这个 bug;它改变了响应侧的读取节奏,让一个已存在多年的 HTTP/1 写入竞态第一次稳定地浮出水面。

故障现象

第一个客户案例是嵌套图片处理:内层 worker 用 Images binding 把 R2 里的多张大图合成 JPEG; 外层再通过 URL 接口压缩、转码、缩放。真正被截断的是内层 binding 的返回路径,但外层转换首先看到了错误。

1 读取素材 从 R2 获取背景 JPEG 和多层 PNG 覆盖图。
2 内层合成 Images binding 合成大图,并返回带 Content-Length 的 200 响应。
3 响应被截断 例如预期数 MB,实际只到约 200 KB 后连接结束。
4 外层继续处理 URL 接口尝试读取完整 body,却在中途遇到 EOF。
5 用户可见失败 图片半截渲染、灰底、或直接解码失败。

最迷惑的地方

Images 服务认为自己已经处理完并发送成功,所以业务日志、状态码、服务端错误指标都不明显。

最有价值的线索

失败时实际收到的数据量接近生产环境 socket buffer 的大小,暗示问题可能发生在写缓冲边界。

复现边界

本地 macOS、Debian VM、直接 curl、回放请求都很难触发;完整生产路径和真实并发更容易撞到。

排查路径

团队没有一开始就定位到 hyper,而是沿着链路逐层排除:客户配置、超时、hyper 版本、Workers runtime、 中间层服务、Images 服务内部逻辑。真正突破来自内核级 syscall 观察。

做出模拟客户嵌套流程的 worker,再逐层剥离,最终只用 binding 就能批量触发截断。

截断与请求耗时不相关,因此不像是某个固定超时窗口主动关闭连接。

0.14、1.7、1.8 都存在问题,说明不是简单升级就能拿到现成修复。

分布式 tracing 和中间层 instrumentation 显示:响应离开 Images 服务时已经被截断。

只追踪必要 syscall,比较成功与失败请求,终于看到失败请求只写出第一段数据后立刻 shutdown。

观测也会改变系统:strace 如果过滤太宽,会引入足够的时序开销,让竞态消失。 这进一步证明问题和毫秒级时序有关,而不是稳定的业务逻辑错误。

根因:flush 的 Pending 被忽略

hyper 的 HTTP/1 连接由一个调度循环驱动,大致负责读请求、写响应、flush 写缓冲、判断是否继续读。 问题在于:循环调用 flush 后丢弃了返回状态。如果底层 socket 暂时不可写,flush 会返回 Pending,表示“还没发完,等可写后再来”。但这个信号被忽略后,连接生命周期继续推进, 最终走到 shutdown。

失败序列

一次截断请求里发生了什么

  • Images 完成图片编码,把完整 body 放进 hyper。
  • hyper 从编码角度认为写入完成,进入关闭写入的状态。
  • 第一次 flush 只把少量字节写进 socket;内核缓冲区满了。
  • 剩余大部分 body 还在 hyper 内部缓冲。
  • flush 返回 Pending,但调度循环没有停下来等待。
  • 连接执行 shutdown,客户端收到 EOF。
关键区别

“编码完成”不等于“已经送达”

应用层把完整响应交给 HTTP 库,只说明响应体已经生成;网络层还必须把内部缓冲中的字节真正写进 socket。 当读端很快时,两者看起来几乎同时完成;当读端稍慢时,这个差异就会变成数据丢失。

// 简化伪代码:问题不是 write,而是 flush 状态被忽略
poll_read();
poll_write();
flush_state = poll_flush();

// 错误形态:即使 flush_state 是 Pending,也继续判断连接可结束
if (!wants_read_again()) {
  finish_connection();
}
成功请求
flush 完成
失败请求
提前关闭
正确等待
等待可写

修复方案

团队先写了一个确定性测试:自定义 TCP stream wrapper,让第一次写入接收少量数据,后续写入一直返回 Pending,模拟“socket 缓冲区满、读端暂时不消费”的情况。测试验证旧行为会在剩余数据仍在缓冲时 shutdown;修复后则会等待。

初版想法

在调度循环里处理 Pending

如果 flush 没完成,就让循环返回 Pending,等待 runtime 在 socket 可写时唤醒任务。

潜在副作用

可能影响读轮询和 keepalive

过早从 dispatch loop 返回,可能降低同连接上其他操作的轮询频率,也不够优雅地处理 keepalive。

最终补丁

在 shutdown 前 flush

保留调度循环行为,只在真正关闭底层 IO 前确认写缓冲已经清空,把修复放在最窄的位置。

// 简化伪代码:最终思路
poll_shutdown(context) {
  wait_until(poll_flush(context) is Ready);
  shutdown_underlying_io();
}
结果:Cloudflare 内部 fork 应用补丁后,观察到所有字节都在 shutdown 前写出; 初始报告客户也确认问题消失。补丁和确定性测试已合入 hyper 上游 PR #4018,未来版本会带上这个修复。

这篇文章最值得带走的经验

不要把 200 OK 当作端到端成功。 状态码只能说明协议头部层面的成功;body 是否完整,仍要看 Content-Length、EOF、解码结果和底层写入事实。
应用层 observability 对底层竞态有盲区。 日志、trace、业务指标都可能显示“已发送”,但 syscall 才能说明实际写了多少字节、何时 shutdown。
快路径优化会改变时序。 新路径整体更快,却也改变了读写节奏;几毫秒背压足以把隐藏多年的库 bug 暴露出来。
异步 API 的 Pending 是协议承诺,不是提示信息。 一旦丢弃 Pending,就等于把“还没完成”的状态误当成“可以继续收尾”。
好的修复通常需要确定性测试保护。 Cloudflare 没只靠生产复现,而是构造受控 socket 背压场景,让竞态可以稳定触发并防止回归。