成功状态掩盖了数据丢失
服务端完成图片编码并返回成功;客户端却只收到响应体的一部分,外层转换再报 EOF 或解码失败。
Cloudflare Images · hyper HTTP/1 · Rust
Cloudflare 这篇文章讲的是一次很典型的生产级竞态排查:图片转换请求返回成功状态码, 但大图响应体只发出一小段。最终根因不是业务代码,而是 hyper HTTP/1 在 socket 还没 flush 完时过早 shutdown。
Cloudflare Images binding 在 2025 年底改造为更直接的本机路径后,某些大图转换请求开始间歇性失败:
内层图片处理返回 200,但响应体被截断。经过六周排查,团队发现问题藏在 Rust HTTP 库
hyper 的 HTTP/1 连接生命周期里:当 socket 写缓冲因为读端稍慢而满了,hyper 的 flush 返回
Pending,但调度循环没有保留这个信号,随后连接被 shutdown,导致剩余数据永远没写出去。
服务端完成图片编码并返回成功;客户端却只收到响应体的一部分,外层转换再报 EOF 或解码失败。
只有当响应较大、读端略慢、socket 缓冲区被填满时,flush 才会挂起;简单 curl 往往复现不了。
最终补丁在执行 socket shutdown 前先 poll flush,确保内部缓冲区剩余字节全部送入底层 IO。
Images binding 让 Workers 能以 API 的方式调用 Cloudflare Images 做图片转换、合成、转码等操作。 最初绑定路径沿用 Cloudflare 的内部流量入口 FL;后来团队把路径改成同机内部 worker binding,通过 Unix socket 直接连到 Images 服务,以减少 FL 处理链路带来的开销,并让 binding 发布节奏不再受 FL 约束。
FL 读响应很快,socket 写缓冲很少被填满;hyper 里已有的问题长期被隐藏。
路径整体更直接,但新的读端节奏偶尔让 socket 出现几毫秒背压,正好撞上 hyper 的竞态窗口。
第一个客户案例是嵌套图片处理:内层 worker 用 Images binding 把 R2 里的多张大图合成 JPEG; 外层再通过 URL 接口压缩、转码、缩放。真正被截断的是内层 binding 的返回路径,但外层转换首先看到了错误。
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。
hyper 的 HTTP/1 连接由一个调度循环驱动,大致负责读请求、写响应、flush 写缓冲、判断是否继续读。
问题在于:循环调用 flush 后丢弃了返回状态。如果底层 socket 暂时不可写,flush 会返回
Pending,表示“还没发完,等可写后再来”。但这个信号被忽略后,连接生命周期继续推进,
最终走到 shutdown。
Pending,但调度循环没有停下来等待。应用层把完整响应交给 HTTP 库,只说明响应体已经生成;网络层还必须把内部缓冲中的字节真正写进 socket。 当读端很快时,两者看起来几乎同时完成;当读端稍慢时,这个差异就会变成数据丢失。
// 简化伪代码:问题不是 write,而是 flush 状态被忽略
poll_read();
poll_write();
flush_state = poll_flush();
// 错误形态:即使 flush_state 是 Pending,也继续判断连接可结束
if (!wants_read_again()) {
finish_connection();
}
团队先写了一个确定性测试:自定义 TCP stream wrapper,让第一次写入接收少量数据,后续写入一直返回
Pending,模拟“socket 缓冲区满、读端暂时不消费”的情况。测试验证旧行为会在剩余数据仍在缓冲时
shutdown;修复后则会等待。
如果 flush 没完成,就让循环返回 Pending,等待 runtime 在 socket 可写时唤醒任务。
过早从 dispatch loop 返回,可能降低同连接上其他操作的轮询频率,也不够优雅地处理 keepalive。
保留调度循环行为,只在真正关闭底层 IO 前确认写缓冲已经清空,把修复放在最窄的位置。
// 简化伪代码:最终思路
poll_shutdown(context) {
wait_until(poll_flush(context) is Ready);
shutdown_underlying_io();
}