The Images service, built in Rust on Workers, runs on every machine in Cloudflare’s edge network. To handle client connections, we use hyper, an open-source HTTP library for Rust.Images 服务是用 Rust 写的,跑在 Workers 上,部署在 Cloudflare 边缘网络的每一台机器里。为了处理客户端连接,我们用了 hyper,这是一个 Rust 的开源 HTTP 库。
Last year, we introduced the Images binding to enable custom, programmatic workflows for processing remote images in Workers. At the end of 2025, we rearchitected the binding to provide a more direct, local connection between the Workers runtime and the Images service.去年,我们推出了 Images 绑定,方便开发者在 Workers 里写代码处理远程图片。2025 年底,我们重构了这个绑定,让 Workers 运行时和 Images 服务之间能直接连上。
Shortly after rollout, we received reports that transformation requests from the binding were failing — but only intermittently and only for larger images. Even stranger, the responses for these requests returned a 200 status without any errors logged. The image data was simply cut short: A response that should have been two megabytes might arrive with a few hundred kilobytes instead.刚上线没多久,就有人报障说,绑定发起的转换请求老是失败。这事儿挺怪,它不是一直失败,只在处理大图片时偶尔发生。更奇怪的是,请求返回的状态码是 200,日志里什么错误也没有。图片数据就是被截断了:本来该有 2MB 的响应,传回来可能只有几百 KB。
We spent six weeks chasing a nearly invisible bug — a race condition that occurred only under specific conditions — in the hyper library that impacted how the Images binding returned processed image data back to the client. In the end, it took four lines of code to fix it.为了抓这个几乎看不见的 Bug,我们折腾了六个礼拜。这其实是个竞态条件,只在特定情况下才会触发。它影响了 Images 绑定把处理好的图片数据传回给客户端的过程。最后,只改了四行代码就修好了。
Hops, handoffs, and hyper跳转、交接与 hyper
When developers build on Cloudflare, they compose full-stack applications from a set of platform services that are accessible to Workers through bindings. Bindings provide direct APIs to resources on the Developer Platform like compute, storage, AI inference, and media processing.开发者在 Cloudflare 上做应用,都是通过绑定(bindings)调用各种平台服务。绑定提供了一套直接的 API,让你可以用计算、存储、AI 推理和媒体处理这些资源。
The Images binding decouples image optimization from delivery; you can transcode, composite, or manipulate images without needing to return the output as an HTTP response. It also lets you apply optimization parameters in any order, rather than following the fixed sequence imposed by the URL interface. Here, a worker can pass image data directly to the Images API, chain operations together, and get the processed result back as a stream: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();
At a high level, this is how image data moves through our various services:简单来说,图片数据在我们的服务间流转的过程是这样的:
The pipe represents a socket connection between the intermediary and Images, where data is handed off from one process to the next through the kernel’s buffer.管道代表中间件和 Images 服务之间的 Socket 连接。数据通过内核缓冲区,从一个进程交接到下一个进程。
The binding communicates with Images through a socket connection managed by the Workers runtime. A socket connection is a communication channel between two processes. Each end of the socket has buffers that are managed by the operating system’s kernel; these buffers are temporary holding areas where data sits after one side writes it but before the other side reads it.绑定通过 Workers 运行时管理的 Socket 连接和 Images 通信。Socket 连接就是两个进程之间的通信通道。Socket 两端都有内核管理的缓冲区。数据写进去,还没被读出来之前,就先存在这儿。
Hyper manages the connection on the Images service’s side, reading incoming requests from the socket and writing responses back to it.Hyper 负责 Images 服务那一端的连接,从 Socket 里读请求,再把响应写回 Socket。
When a request uses the Images binding, the Images service reads the input, performs the requested optimization operations, and encodes the result. It then passes the entire encoded image to hyper as a single in-memory block. 当请求用到 Images 绑定时,Images 服务会读取输入,执行优化操作,然后编码。最后,它把整个编码好的图片作为一个内存块传给 hyper。
Hyper writes this response data into its own internal buffer. At this point, hyper considers the encoding work as complete, since it has all the bytes that it needs to send. The next step is to flush its internal buffer to the socket’s outbound buffer, moving the data from the Images service to the intermediary on the other end.Hyper 把这些响应数据写进自己的缓冲区。到这一步,hyper 觉得编码工作完成了,因为它已经拿到了要发出去的所有字节。接下来,它要把缓冲区里的东西刷(flush)到 Socket 的发送缓冲区里,把数据从 Images 服务传给另一端的中间件。
If the reader on the other end is fast, then hyper can flush everything in one pass — the outbound buffer will have room because the reader is consuming data as quickly as it arrives. Once all data is sent, hyper issues a shutdown on the socket, signaling that the connection is finished and no more data will be written. But if the reader is slower (even by a few milliseconds), then the outbound buffer fills up, and hyper needs to wait until there’s room to continue writing.如果另一端的读取速度够快,hyper 一次就能刷完,因为读取方在不断拿数据,发送缓冲区总有空位。发完所有数据后,hyper 会关闭 Socket,表示连接结束。但如果读取方慢了一点(哪怕只慢几毫秒),发送缓冲区就会满,hyper 就得等着,直到有空位才能继续写。
Taking the local走本地连接
All incoming traffic on Cloudflare's network passes through FL, an internal intermediary service that runs security and performance features and routes requests to the appropriate backend. When we first launched the binding, image data flowed from the Workers runtime, through FL, to the Images service.Cloudflare 网络的所有流量都要经过 FL,这是一个内部中间件,负责安全和性能功能,并把请求路由到后端。绑定刚发布时,图片数据是从 Workers 运行时出来,经过 FL,再到 Images 服务的。
This path was a natural fit for our initial release and follows the same architecture as our URL interface. Over time, though, this coupling with FL became a constraint: Every change to the binding had to follow FL’s release cycle.这个路径在最开始很自然,和 URL 接口的架构一样。但久而久之,这种对 FL 的依赖成了束缚:每次改动绑定,都得跟着 FL 的发布周期走。
In December 2025, the Images team replaced FL with a new intermediary service, an internal worker binding that runs on the same machine. In the original architecture, data moved through FL over network sockets; this path carried the overhead of FL’s full processing pipeline, such as DNS lookups and routing.2025 年 12 月,Images 团队用一个新的内部 Worker 绑定替换了 FL,这个绑定直接跑在同一台机器上。原来的架构里,数据要通过网络 Socket 穿过 FL,这会带来 DNS 查询和路由等处理开销。
The internal binding replaced these with Unix sockets to directly connect the services on the same machine, bypassing FL and the overhead of the network stack. This made the request path to Images faster and gave the team independent control over binding releases.内部绑定换成了 Unix Socket,让同一台机器上的服务直接连通,绕过了 FL 和网络协议栈的开销。这让请求路径变快了,团队也能独立控制绑定的发布。
Within days of the rollout, we received our first customer report.上线没几天,我们就收到了第一个客户反馈。
200 OK (not OK)200 OK(其实并不 OK)
The first sign of trouble came from a customer with a non-standard setup: two layers of image processing, where one pipeline was nested inside another.第一个出问题的客户用的是一种非标准配置:两层图片处理,一层套着另一层。
First, their worker used the Images binding to composite multiple large source images from R2 — a JPEG background plus PNG overlay layers — into a single combined JPEG. Second, they further compressed, transcoded, and resized the result through the URL interface.首先,他们的 Worker 用 Images 绑定把 R2 里的多张大图合成了单张 JPEG(一张背景图加 PNG 遮罩)。然后,他们又通过 URL 接口对结果进行了进一步压缩、转码和缩放。
The bug originated in the inner pipeline’s return path, where the response was truncated before reaching the outer pipeline.Bug 出在内层处理路径的返回阶段,响应在到达外层之前就被截断了。
The inner pipeline (transformation binding) handled compositing. The outer pipeline (transformation URL) handled delivery optimizations like scaling and format conversion. This layered approach meant that when the inner pipeline silently returned a truncated response, the only visible error appeared one level up:内层(转换绑定)负责合成,外层(转换 URL)负责缩放和格式转换。这种分层结构导致内层静默地返回了一个截断的响应时,唯一的可见错误出现在上一层:
error reading a body from connection: end of file before message length reached
The outer pipeline received HTTP 200 from the inner one, with a Content-Length header that promised several megabytes. The actual body was only a fraction of that: In one request, only ~200 KB arrived out of an expected 3.3 MB. The error surfaced in the outer pipeline, but the truncation could have originated in the binding, the intermediary service, the Images service, or somewhere in between.外层收到了内层的 HTTP 200,Content-Length 头写着好几 MB,但实际收到的数据只有一点点:有一次请求,本该是 3.3 MB,结果只传过来约 200 KB。错误是在外层暴露出来的,但截断可能发生在绑定、中间件、Images 服务或者中间的任何地方。
When a browser receives a truncated image, the result is visible. Depending on the format, the image either renders partially (e.g., with the bottom half missing or gray) or fails to decode entirely, instead displaying a broken image.浏览器收到截断的图片,结果很直观。根据格式不同,图片要么显示一半(比如下半部分缺失或变成灰色),要么根本解不开,直接显示一个损坏的图标。
Debugging in the dark盲人摸象式排查
From here, we worked inward through the request path, testing each layer to isolate where the truncation was happening. Some of these efforts hit dead ends; others left breadcrumbs that narrowed the search:我们开始沿着请求路径往回查,逐层测试,想找出截断到底发生在哪儿。有些尝试走进了死胡同,但有些线索帮我们缩小了范围:
Building a reproduction. We built a worker that mimicked the customer’s nested setup, then stripped away layers until we could trigger the bug with the binding alone. A small script let us fire requests in batches. In one early run, 19 out of 25 requests failed. The amount of data that did arrive — roughly 200 KB — was suspiciously close to the size of the socket buffer in production. This confirmed that the problem wasn’t tied to the customer’s configuration and gave us a reliable way to trigger the bug on demand.构建复现环境。我们写了一个 Worker 模拟客户的嵌套配置,然后一层层拆掉,直到只用绑定就能复现 Bug。写个小脚本批量发请求,早期的测试里,25 次请求有 19 次失败。传回来的数据量大概 200 KB,这数字和生产环境的 Socket 缓冲区大小巧合得让人怀疑。这说明问题和客户的配置无关,我们终于能稳定复现了。
Investigating timeouts. Early on, we suspected the truncation might be related to timeout behavior (i.e., the connection was being closed after a time limit). This theory didn’t hold, as the truncation wasn’t correlated with request duration.调查超时。起初我们怀疑是超时导致的(比如连接到时间限制就断了)。但这理论站不住脚,因为截断和请求耗时没关系。
Updating hyper version. When the bug was first reported, we were running 0.14.x, while the latest hyper version was around 1.8.x. We tested across hyper versions 0.14, 1.7, and 1.8, just in case the most obvious answer was the correct (and easiest) one. But the bug appeared in each version, which meant that there wasn’t an upstream fix.升级 hyper 版本。刚报 Bug 时我们用的是 0.14.x,当时最新版是 1.8.x。我们把 0.14、1.7 和 1.8 都测了一遍,万一是最简单的版本问题呢?结果每个版本都有这 Bug,说明上游没修过。
Reproducing locally. We ran local integration tests on macOS and a Debian VM. Even under considerable load, our local requests never triggered any failure. Making direct curl requests to the binding socket and replaying captured requests always seemed to work. The bug only appeared on the full production path when there was real concurrency and a real Workers runtime client on the other end of the socket. This led us to suspect the runtime itself.本地复现。我们在 macOS 和 Debian 虚拟机上跑集成测试,压力再大也复现不出来。直接用 curl 请求绑定 Socket 或者重放抓包数据,一切正常。只有在生产环境的完整路径下,当有真实的并发和真实的 Workers 运行时客户端在 Socket 另一端时,Bug 才会出现。这让我们怀疑是运行时本身的问题。
Ruling out the Workers runtime. We examined the HTTP client that the Workers runtime uses to communicate with Images through the binding socket. None of the traces from either side of the connection showed any syscalls that indicated an unexpected close or early termination. We observed that the client behaved correctly and multiple other services used the same client without issues.排除 Workers 运行时。我们查了 Workers 运行时用来和 Images 通信的 HTTP 客户端。连接两端的追踪记录里,没有任何系统调用显示连接被意外关闭或提前终止。客户端表现正常,其他服务也在用它,都没问题。
Distributed tracing. By inspecting request traces end-to-end, we confirmed that the truncated body was already present before it reached the outer transformation layer in the customer’s setup. That narrowed the problem to the inner pipeline — the binding path through the Images service.分布式追踪。通过全链路追踪,我们确认在数据到达客户外层转换逻辑之前,响应体就已经被截断了。问题锁定在内层路径——即通过 Images 服务的绑定路径。
Instrumenting the intermediary service. We added instrumentation to the intermediary service to measure body sizes before forwarding the response data. The bodies were already truncated by the time they left the Images service, so the intermediary was ruled out.给中间件加监控。我们在中间件里加了代码,统计转发响应前的数据大小。结果发现数据离开 Images 服务时就已经被截断了,所以中间件没问题。
Deeper tracing within the Images service. At the service level, the request was processed, the image was properly encoded, and the response was sent with HTTP
200.深入 Images 服务追踪。在服务层面,请求处理完了,图片编码也正常,响应带着 HTTP 200 发出去了。
The only consistent signal was that the bug was timing-dependent: It appeared only on the production path, with real concurrency, and only for larger images.唯一明确的信号是:这 Bug 和时间有关。它只在生产环境、有高并发、处理大图片时才会出现。
A kernel of truth内核里的真相
Tools for application-level debugging told only what the system thought it was doing. But according to the system, everything was fine: Tracing said the response was sent; logging reported no errors, and the Images service returned 200 on every request.应用层的调试工具只能告诉你系统“以为”自己在做什么。系统觉得一切正常:追踪显示响应已发出,日志没报错,Images 服务对每个请求都回了 200。
To see what the system was actually doing, we attached strace to the Images service. strace records the syscalls that a process makes to the kernel, which could show us exactly which bytes were written, when a shutdown was called, and whether the client sent any termination signal.为了看系统到底在干什么,我们给 Images 服务挂上了 strace。strace 会记录进程对内核发出的所有系统调用,这能让我们精确看到写了哪些字节、什么时候调了 shutdown,以及客户端有没有发过终止信号。
Setting up the trace was delicate. strace works by intercepting syscalls as they happen, which adds a small amount of timing overhead to each one. Filtering for a narrow set of syscalls kept that overhead minimal. Broadening the filter, however, slowed the process just enough to shift the timing between the flush and the shutdown check — and make the bug disappear entirely. That alone reinforced our theory that the issue was timing-sensitive.配置追踪很麻烦。strace 通过拦截系统调用工作,这会增加一点点延迟。如果过滤规则设得窄,延迟还行;如果设宽了,处理速度变慢,flush 和 shutdown 检查之间的时间差就会变,Bug 居然消失了。这再次证明了问题对时间非常敏感。
Using a reproduction worker, we triggered the bug and compared the syscall output between successful and failing requests.我们用复现用的 Worker 触发 Bug,对比了成功和失败请求的系统调用输出。
In a successful request, the response is written in chunks as the socket buffer allows, with shutdown called only after all the data is sent. For example, this may look like:请求成功时,响应会根据 Socket 缓冲区的容量分块写入,只有在所有数据发完后才会调用 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
When we reproduced the bug, a failing request looked like:复现 Bug 时,失败的请求长这样:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
shutdown(42, SHUT_WR) = 0
Here, there is only one write — just enough for the headers and a sliver of the body — before the shutdown is immediately called. Out of a 14.9 MB response, only about 219 KB was sent. The remaining ~14.8 MB of image data never left hyper’s internal buffer, nor was there any termination signal from the client between the write and the shutdown. Instead, the Images service prematurely shut down the connection on its own, genuinely believing it was finished.这里只有一次 write——只够写完头部和一小截数据——然后立刻就调用了 shutdown。14.9 MB 的响应,只发出了 219 KB。剩下的 14.8 MB 数据根本没离开 hyper 的内部缓冲区,客户端也没发终止信号。Images 服务自己提前关掉了连接,因为它真心觉得活儿干完了。
The failing requests confirmed that the bug was a race condition that triggered intermittently. Whether a request succeeded or failed depended on whether the flush and shutdown operations overlapped, which changed from request to request. When the buffer was still full at the exact moment that hyper decided the connection was finished, data was lost.失败的请求证实了这是个竞态条件。请求成功还是失败,取决于 flush 和 shutdown 操作是否撞车,而这每次都不一样。在 hyper 判定连接结束的那一刻,如果缓冲区正好还是满的,数据就丢了。
When the reader consumes slower than hyper writes, the outbound buffer fills up. If hyper shuts down the connection before the buffer drains, then only a fraction of the response makes it to the intermediary; this incomplete data gets forwarded back to the Workers runtime and the client.当读取方比 hyper 写得慢时,发送缓冲区就会满。如果 hyper 在缓冲区排空前就关掉连接,那只有一小部分响应能传给中间件;这些残缺的数据会被转发给 Workers 运行时和客户端。
The December rearchitecture didn't introduce this bug, which had been present in hyper for years across multiple major versions. But the new intermediary changed who was reading on the response side of the socket. Our working theory is that FL, the previous intermediary, consumed data fast enough that the socket buffer rarely filled during a response. The new reader read at a pace that occasionally let the buffer fill during larger responses.12 月的重构并没有引入这个 Bug,它在 hyper 里已经存在好几年了。但新的中间件改变了 Socket 响应端的读取方式。我们的推测是,之前的 FL 中间件读得够快,Socket 缓冲区很少在响应过程中填满。而新的读取方偶尔会让缓冲区在大响应传输时填满。
These few milliseconds of backpressure, introduced by an improvement that made everything else faster, were all it took to surface a flaw that had been hiding in plain sight.一次为了让系统变快所做的改进,反而因为几毫秒的背压,让这个隐藏已久的缺陷暴露了出来。
Inside the dispatch loop调度循环内部
Hyper's HTTP/1 connection lifecycle is driven by a state machine in a file called dispatch.rs. It runs a loop that reads requests, writes responses, flushes the write buffer to the socket, and decides when to shut down. In simplified form:Hyper 的 HTTP/1 连接生命周期由 dispatch.rs 里的状态机驱动。它跑一个循环:读请求、写响应、把缓冲区刷到 Socket,然后决定什么时候 shutdown。简化一下就是:
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(()));
}
}
}
More precisely, the let _ before poll_flush is where the bug lives.更准确地说,poll_flush 前面的 let _ 就是 Bug 的藏身之处。
In Rust, let _ = expr discards the expression's result, including Poll::Pending, the signal that the flush isn’t done yet. The flush might still have megabytes sitting in its buffer, but the loop never finds out.在 Rust 里,let _ = expr 会丢弃表达式的结果,包括 Poll::Pending,也就是“还没刷完”的信号。缓冲区里可能还有好几 MB 数据,但循环根本不管。
When a request fails, this is the exact sequence of events:当请求失败时,事件序列如下:
The Images service finishes encoding the image and hands the entire response to hyper as a single in-memory block.Images 服务完成编码,把整个响应作为一个内存块交给 hyper。
Hyper writes the block into its internal buffer and marks its write state as
Writing::Closed. From an encoding standpoint, the work is done — there is nothing left to encode.Hyper 把块写进内部缓冲区,把写状态标记为 Writing::Closed。从编码角度看,活儿干完了——没东西要编了。Hyper calls
poll_flushto move the buffered data to the socket. In our previous example, the socket accepted about 219 KB. The remaining ~14.8 MB stays in hyper's buffer. The socket is full, so the kernel returnsPoll::Pending.Hyper 调用 poll_flush 把数据移到 Socket。在之前的例子里,Socket 只接收了 219 KB。剩下的 14.8 MB 还在 hyper 的缓冲区里。Socket 满了,内核返回 Poll::Pending。poll_loopdiscards thePoll::Pendingwithlet _.poll_loop 用 let _ 丢弃了 Poll::Pending。It checks
wants_read_again(). The full request was already received, so this returnsfalse.它检查 wants_read_again()。请求已经收全了,所以返回 false。poll_loopreturnsPoll::Ready(Ok(())), signaling that the loop is finished, even though the flush is not.poll_loop 返回 Poll::Ready(Ok(())),表示循环结束,尽管 flush 还没完。poll_shutdown()fires. TheSHUT_WRsyscall is issued.poll_shutdown() 触发,发出 SHUT_WR 系统调用。The client receives 219 KB and an EOF (end-of-file) indicating that the connection is closed, even though it expects 14.9 MB.客户端收到了 219 KB 和一个 EOF,以为连接关了,尽管它还在等 14.9 MB。
In the second step, hyper marks the write operation as complete as soon as the response body is buffered (i.e., when encoding is finished), rather than when it has actually been flushed. Most of the time, the flush completes in a single pass and this distinction is invisible. On the rare occasions when the socket buffer is full, the flush has to wait — even though hyper doesn't. The bytes are still sitting in hyper’s buffer, waiting to be flushed to the socket. Hyper proceeds to shut down the connection with this data still in the buffer.第二步的问题在于,hyper 只要把响应体缓冲好(编码完成)就认为写操作完成了,而不是等它真正刷出去。大多数时候,flush 一次就搞定,这个区别看不出来。但偶尔 Socket 缓冲区满了,flush 就得等,而 hyper 却不等。字节还在 hyper 的缓冲区里等着刷进 Socket,hyper 就直接关掉连接了。
This also explains why curl never triggered the bug. Curl reads data as fast as it arrives: The socket buffer never fills, the flush always completes immediately, and the discarded return value is harmless. The production path, with a reader that occasionally paused for a few milliseconds, was the only configuration where the buffer filled at exactly the wrong moment.这也解释了为什么 curl 测不出来。curl 读得飞快:Socket 缓冲区永远填不满,flush 总是立即完成,丢弃返回值也没事。只有生产环境那种偶尔会卡几毫秒的读取方,才会在缓冲区满的瞬间触发这个 Bug。
Don’t forget to flush别忘了 Flush
After weeks of investigation, the fix itself was conceptually simple. Hyper needed to check whether the flush was actually done before moving on.查了几个礼拜,修复方案其实很简单:hyper 在继续下一步之前,得先确认 flush 是否真的完成了。
Our reproduction worker confirmed that the bug existed, but it couldn't tell us why a given request failed. Before writing the fix, we needed a test that could trigger the exact socket conditions inside hyper.复现 Worker 证明了 Bug 存在,但没法解释为什么某个请求会失败。写修复代码前,我们得先写个测试,在 hyper 内部模拟出那个 Socket 条件。
We knew the conditions that triggered the bug: a socket that accepts one chunk of data and then blocks. To test with a controlled scenario, we built a custom wrapper around a TCP stream that simulated a full socket buffer. The wrapper accepted 8 KB on the first write, then returned Poll::Pending on every subsequent write, mimicking a reader that stopped draining the buffer.我们知道触发条件:Socket 接收了一块数据后就阻塞。我们给 TCP 流写了个包装器来模拟满的 Socket 缓冲区。包装器第一次 write 接收 8 KB,之后每次都返回 Poll::Pending,模拟读取方停止消费的情况。
The test sent a 500 KB response through this constrained socket and checked whether hyper called shutdown while 492 KB was still buffered. Without a fix, it did. With the fix, it waited.测试通过这个受限的 Socket 发送 500 KB 响应,检查 hyper 是否会在 492 KB 还在缓冲区时就调用 shutdown。没修之前,它确实会。修好后,它会等着。
Initially, we applied the fix in hyper’s dispatch loop. Instead of discarding the result of poll_flush, we checked to see whether the flush was actually done:起初,我们在 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(()));
}
If the flush hasn't completed, then the loop returns Poll::Pending to the asynchronous runtime. The runtime waits for the socket to become writable, then wakes the task back up to continue the flush. The connection shuts down only after all data has been sent.如果没刷完,循环就返回 Poll::Pending 给异步运行时。运行时等 Socket 变得可写时,再唤醒任务继续刷。连接只会在所有数据发完后才关闭。
When we deployed this fix, we observed that every byte was written and the shutdown was called only after the buffer was actually empty. The customer who made the first report also confirmed that the issue disappeared.部署这个修复后,我们观察到每个字节都写出去了,shutdown 只有在缓冲区真正空了之后才调用。当初报障的客户也确认问题消失了。
While our initial solution worked, the dispatch loop wasn’t the right place for the fix. Returning Poll::Pending early could slow down other operations on the same connection by reducing how frequently reads are polled, causing unintended backpressure. It also doesn't correctly handle keepalive connections, where a single connection handles multiple requests in sequence — these should remain reusable even while the previous response is still being flushed. Neither issue affected our particular service (where keepalive is disabled), but both could affect other hyper users if the fix were contributed upstream.虽然初步方案管用,但调度循环不是修复的最佳位置。过早返回 Poll::Pending 可能会因为降低读取轮询频率而拖慢同一连接上的其他操作,造成意外的背压。它也没处理好 keepalive 连接,那种连接上会连续处理多个请求,前一个响应还在刷的时候,连接应该还能重用。虽然我们的服务禁用了 keepalive,但如果提交到上游,可能会影响其他 hyper 用户。
We traced through hyper's connection lifecycle and found a more targeted approach. Rather than changing how the dispatch loop behaves, we applied the fix at the point where shutdown is actually called. Before shutting down the socket, hyper should first flush any remaining data in its buffer:我们重新梳理了连接生命周期,找到了更精准的办法。不改调度循环,而是改在真正调用 shutdown 的地方。Hyper 在关掉 Socket 前,应该先刷掉缓冲区里剩下的数据:
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)
}
This leaves the dispatch loop unchanged. It adds a flush only at the exact point where data loss would otherwise occur — the moment before shutdown.这样调度循环不用动。只在数据丢失风险发生的点——shutdown 之前——加一个 flush 就行了。
What stayed with us这次经历留给我们的教训
None of the tools at the application level surfaced any errors, crashes, or log entries that provided useful clues. Application-level observability can have a blind spot for bugs that live below its awareness.应用层的工具没发现任何错误、崩溃或日志线索。应用层的可观测性对底层的 Bug 往往有盲区。
The failure occurred intermittently, scaled with response size, couldn’t be reproduced with simple tools like curl, and disappeared when we observed the system more closely. These signals pointed to a timing-dependent bug in the connection layer, not in the application logic.Bug 偶尔发生,随响应大小变化,curl 复现不了,观察得越细它反而越不见。这些信号都指向连接层的竞态条件,而不是应用逻辑的错。
Our breakthrough came from using kernel-level tooling with strace, the one layer that records what actually happened on the socket. The underlying bug lived in the few milliseconds between a partial flush and a premature shutdown — a window that opened only after we made the system faster.我们的突破口是 strace 这种内核级工具,它记录了 Socket 上到底发生了什么。底层的 Bug 就藏在部分 flush 和提前 shutdown 之间的那几毫秒里——这个窗口,是在我们让系统变快之后才打开的。
We merged our fix and the deterministic test into hyperium/hyper via PR #4018. It will be available in a future hyper release, ensuring that any service using hyper’s HTTP/1 implementation won’t lose response data to the same race condition.我们把修复代码和确定性测试合并到了 hyperium/hyper 的 PR #4018 中。它会在未来的版本中发布,确保所有使用 hyper HTTP/1 实现的服务都不会再因为这个竞态条件丢失数据。
In the meantime, we’re running an internal fork with the patch applied. This fix stabilized the binding’s architecture, creating a reliable foundation to expand its functionality.在此期间,我们内部维护了一个打了补丁的分支。这个修复稳定了绑定的架构,为扩展功能打下了可靠的基础。
The Images binding initially covered only transformations of remote images. Earlier this month, we announced that the Images binding now supports operations for hosted images, giving developers a unified way to build media-rich applications on Cloudflare.Images 绑定最初只覆盖远程图片的转换。本月早些时候,我们宣布绑定现在支持托管图片操作,让开发者能用统一的方式在 Cloudflare 上构建媒体丰富的应用。
Read more about how the binding works in our documentation.在我们的文档中了解更多关于绑定的工作原理。




