vatt'ghern jaskier's ballads
本文 2 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

客戶端收到一個乾淨的 HTTP 200,沒有任何 error log,body 卻只有 219 KB——剩下的 14.8 MB 還躺在 hyper 的 buffer 裡,而連線已經被 SHUT_WR 關掉了。這個 bug 已經在 hyper 裡跨多個大版本活了好幾年。

Cloudflare 怎麼挖出 hyper 的截斷 bug

是 Cloudflare Images 團隊一次 silent truncation 的獵 bug 過程。表面症狀矛盾到讓人懷疑工具壞了:回應狀態碼是 200、沒有錯誤、圖片就是少了一截;本地怎麼壓測都打不出來、用 curl 也重現不了;越是仔細觀察,failure 越是消失。最後落在 hyper 的 HTTP/1 dispatch loop 裡一行 let _——它把 poll_flush 回傳的 Poll::Pending 丟掉了,於是在 flush 真正完成前就把連線關了。

值得記下的不只是那一行 bug,而是整條路徑:為什麼一個存在多年的缺陷,偏偏在這次重構之後才浮出來;為什麼 application 層的所有觀測工具都對它視而不見;以及為什麼最後的修法只動了四行、卻刻意不去碰 dispatch loop 本身。先把根因的 mechanism 攤開——下面這個模擬讓你直接控制下游讀取的速度,看 socket buffer 在什麼條件下會在 flush 完成前填滿。

sent 0 KB
14.9 MB 的回應被 hyper 一次寫進內部 buffer。把「下游讀取速率」拖低,send buffer 會在 flush 完成前填滿——kernel 回 Poll::Pending,dispatch loop 卻丟掉這個訊號,提早送出 SHUT_WR。拖高讓下游讀得夠快,buffer 就不會在回應期間滿,bug 不觸發。

14.9 MB 的回應被 hyper 一次寫進內部 buffer

下游讀得慢,send buffer 在 flush 完成前填滿,dispatch loop 丟掉 Pending、提早關連線,剩下的 body 就此遺失。

把 FL 換成 Unix socket 之後

故事的起點是一次本來該是純收益的重構。Cloudflare 的 Images binding 讓 Workers runtime 呼叫 Images service 做圖片轉換,原本這條路徑會繞過一個叫 FL 的內部中介。按 Cloudflare 的說法,「At the end of 2025, we rearchitected the binding to provide a more direct, local connection between the Workers runtime and the Images service.」——2025 年底他們重構這個 binding,要在 Workers runtime 與 Images service 之間做出更直接的本機連線。

具體的改動在 2025 年 12 月:「In December 2025, the Images team replaced FL with a new intermediary service, an internal worker binding that runs on the same machine.」FL 被換成一個跑在同一台機器上的內部 worker binding 中介。連線方式也跟著變——「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.」改用 Unix socket 在同機直連,繞掉 FL 與整個網路堆疊的 overhead。

這類重構的預期是延遲下降、吞吐上升,沒有人會把它跟「圖片開始少一截」聯想在一起。事實上,後來證明這個改動並沒有引入任何新 bug——它只是換掉了一個讀取端的角色,而那個角色的讀取速度,恰好決定了一個躺在 hyper 裡多年的缺陷會不會被觸發。重構上線之後,異常才開始出現。

這裡有一個容易被忽略的因果順序值得先講清楚。Unix socket 與一個普通的 TCP socket 在「寫入端被讀取端的速度反向約束」這件事上沒有本質差別:兩者都有一個有上限的 kernel send buffer,寫入方一旦把 buffer 填滿,後續的 write 就只能等讀取方把資料收走、騰出空間才能繼續。換句話說,繞掉網路堆疊降低了 overhead,卻沒有、也不可能取消 backpressure 這個機制本身。真正改變的是時序的分布:FL 與新中介在「把 socket 裡的資料收走」這個動作上,速度落在不同的區間,而這個區間恰好橫跨了那條早就埋好的錯誤路徑的觸發門檻。一次重構若只看吞吐與延遲的平均值,很容易忽略它對尾端時序的擾動——而 race condition 偏偏只活在尾端。

200 OK,body 卻少了一截

第一個矛盾的訊號就足夠讓人皺眉:「the responses for these requests returned a 200 status without any errors logged. The image data was simply cut short」。回應碼是 200、沒有任何 error log,圖片資料就是被截短了。對一個習慣「壞了就會有 5xx 或 exception」的後端工程師來說,這是最難查的一類問題——成功的狀態碼會讓所有監控指標看起來健康。

截斷的規模不小。Cloudflare 給的例子是「A response that should have been two megabytes might arrive with a few hundred kilobytes instead.」本該兩 MB 的回應,可能只到幾百 KB。更具體的一個數字:「In one request, only ~200 KB arrived out of an expected 3.3 MB.」一個請求只收到約 200 KB,預期是 3.3 MB。客戶端那一側偶爾會看到一行錯誤——「error reading a body from connection: end of file before message length reached」,在達到宣告的 message length 之前就先遇到了 EOF。

截斷的比例本身就是一條線索。下面這張圖把 Cloudflare 給的幾個數字並排——同樣的錯誤路徑,截掉的不是固定的 bytes,而是「socket 一次能吞下的量」與「實際 body 大小」之間的差。body 越大,遺失的比例越高。

三個被截斷的回應——送達的只是 socket 一次吞得下的那一截 預期 3.3 MB 送達 ~200 KB(約 6%) 預期 2 MB(舉例) 送達 幾百 KB 預期 14.9 MB 送達 219 KB(約 1.5%) 空心框=宣告的 Content-Length;實心=socket 在關連線前實際吞下的量。 body 越大,固定容量的 send buffer 占的比例越小,遺失也就越多。
三個 Cloudflare 提到的截斷例子。送達量大致鎖在 socket 一次能接收的量級,與 body 大小無關,於是 body 越大、遺失比例越高。資料來源:Cloudflare blog post。

這行錯誤其實已經把方向指出來了:發送端宣告了一個 body 長度(HTTP 回應帶 Content-Length),卻在送完那麼多 bytes 之前就關掉了連線。但「誰提早關了連線、為什麼關」這兩個問題,要再花相當的工夫才答得出來。這個 failure 還有一組讓人抓狂的特徵:「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.」間歇發生、隨回應大小放大、用 curl 重現不出來、而且觀察得越仔細就越不出現。最後一點是 timing-dependent bug 的典型指紋——你一旦放慢系統去看它,那扇時間窗就關上了。

從 timeout 假設一路追到 strace

定位的過程是一連串的排除。下面這條時間線把八個關鍵步驟攤開——拖動把手,看每一步排除了什麼、又把截斷點往哪裡縮。

drag handle along the timeline · 8 steps of the debug hunt

step 1

互動圖表

剝到只用 binding 能觸發、排除 timeout、三版皆重現、本地打不出來,最後靠 strace 看 socket syscall 定位。

最值得學的是排除的次序。第一步是把問題從「客戶複雜的 nested setup」剝乾淨——他們「built a worker that mimicked the customer's nested setup, then stripped away layers until we could trigger the bug with the binding alone」,逐層剝離到只剩 binding 還能觸發。剝乾淨之後出現了一個可量化的 baseline:「In one early run, 19 out of 25 requests failed.」25 個請求壞 19 個,這種失敗率高到足以拿來驗證任何假設。

接著是兩個被否定的方向。timeout:「we suspected the truncation might be related to timeout behavior... This theory didn't hold.」連線是被某個時限關掉的嗎?不是。然後是 hyper 版本——他們「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」。賭最省事的答案(某版的 regression)結果落空:三個版本都中,意味著這不是哪一版引入的、upstream 也沒有現成修法可升級。

最折磨人的是本地環境的沉默:「We ran local integration tests on macOS and a Debian VM. Even under considerable load, our local requests never triggered any failure.」macOS 與 Debian VM 上加大負載也打不出失敗。一個只在 production 流量形態下才出現的 bug,把開發者最依賴的快速 reproduce-fix 迴圈整個拿掉了。原因仍然是時序:本地測試的讀取端往往一啟動就把資料收得飛快,send buffer 還來不及滿,那扇幾毫秒的窗根本沒打開。把負載加大改變的是請求數,不是單一連線上讀取端的相對慢速——而後者才是觸發條件。這也是為什麼 curl 重現不出來:curl 讀得太爽快,從不讓 buffer 在回應期間填滿。

剩下的步驟是把「截斷發生在哪一層」一格一格往下推。distributed tracing 確認「the truncated body was already present before it reached the outer transformation layer」——截斷在抵達外層轉換前就已存在;對中介服務加 instrumentation 後更精確:「The bodies were already truncated by the time they left the Images service」,資料離開 Images service 時就已經少了。到這裡 application 層的每一個觀測點都用過了,卻都只能說「它離開我這層時就壞了」。於是只剩最後一招:「To see what the system was actually doing, we attached strace to the Images service.」掛上 strace,去看 socket 上的 syscall 序列。

那一行 let _,與 shutdown 早於 flush 的競賽

strace 看到的,是一個 write 把約 219 KB 推進 socket、接著一個 shutdown(SHUT_WR) 把寫入方向關掉——而那時 body 還遠遠沒送完。問題不在 socket、不在 kernel,而在 hyper 決定「這條連線寫完了、可以關了」的那段邏輯。下面這張圖把 HTTP/1 dispatch loop 的關鍵幾步畫出來,紅色標記就是 bug 所在。

dispatch loop——寫回應到 socket 的那一段 寫入內部 buffer write state = Writing::Closed let _ = poll_flush(cx) socket 滿 → 收下 219 KB 回 Poll::Pending(被丟掉) wants_read_again()? request 已收完 → false loop 回 Poll::Ready(Ok) poll_shutdown() 發出 SHUT_WR syscall 在 flush 完成之前 client 收到 219 KB + EOF ~14.8 MB 仍在 hyper buffer 預期 14.9 MB bug 所在: Poll::Pending 是「flush 還沒做完」的訊號, let _ 把它丟掉了。 wants_read_again() 只問「還要不要讀 request」,不問「buffer 還有沒有沒送出的 response」 兩個獨立的條件被當成同一件事,於是 loop 在 flush 未竟時就判定收工
根因不是某個 syscall 失敗,而是 dispatch loop 把「request 讀完」誤當成「response 寫完」。資料來源:Cloudflare blog post(2026 年 6 月)。

根因的句子 Cloudflare 寫得很白:「In Rust, let _ = expr discards the expression's result, including Poll::Pending, the signal that the flush isn't done yet... the let _ before poll_flush is where the bug lives.」在 Rust 裡 let _ = expr 會丟掉表達式的結果,包含 Poll::Pending——那正是「flush 還沒完成」的訊號。poll_flush 前面那個 let _ 就是 bug 住的地方。

把整段時序拆開看,每一步單獨都合理,錯在它們被串成一條不該成立的因果。回應太大、一次寫不完,所以「Hyper writes the block into its internal buffer and marks its write state as Writing::Closed.」——資料先進 hyper 自己的 buffer,write state 標成 Writing::Closed。接著 hyper 嘗試把 buffer 推進 socket,但 socket send buffer 有上限:「The socket accepted about 219 KB. The remaining ~14.8 MB stays in hyper's buffer. The socket is full, so the kernel returns Poll::Pending.」socket 只收下約 219 KB,剩下約 14.8 MB 留在 hyper buffer;socket 滿了,kernel 回 Poll::Pending。這個 Pending 是 backpressure,意思是「等 socket 排空再來」。

然後關鍵的誤判發生。loop 不看 flush 還沒完成,轉頭去問另一個問題:「It checks wants_read_again(). The full request was already received, so this returns false. poll_loop returns Poll::Ready(Ok(())), signaling that the loop is finished, even though the flush is not.」它檢查 wants_read_again()——還需要再讀 request 嗎?整個 request 早就收完了,所以回 false。於是 poll_loopPoll::Ready(Ok(())),宣告 loop 結束,儘管 flush 根本還沒做完。「讀完 request」和「寫完 response」是兩個獨立的條件,這裡卻被當成同一件事。

這裡值得停下來想清楚 Poll::Pending 在 async Rust 裡到底是什麼意思。它不是錯誤,也不是「失敗」——它是一個契約:被呼叫的 future 告訴呼叫方「我這次沒做完,請先別管我,等我準備好會透過 waker 叫醒你」。對 poll_flush 來說,回 Pending 等同於說「socket 現在滿了,buffer 裡還有沒送出去的資料,等 socket 排空再回來繼續 flush」。一個正確處理 backpressure 的呼叫方,看到 Pending 就應該把自己的進度也停在這裡、把 Pending 一路往上傳,直到最外層的 executor 安排重試。問題出在 let _ 把這個訊號吞掉了——它讓 poll_flush 的副作用(盡量把資料推進 socket)發生,卻丟掉了它的回傳值,於是「還沒做完」這個資訊在這一行就斷了。程式繼續往下走,彷彿 flush 已經完成。

更精確地說,bug 的本質是兩個獨立狀態被一個布林條件混為一談。一邊是 read side:request 收完了沒?另一邊是 write side:response 送完了沒?這兩件事在 HTTP/1 的一來一回裡時序上很接近,但它們是不同的問題,由不同的緩衝區、不同的 syscall 推動。wants_read_again() 只回答前者,dispatch loop 卻把它的 false 當成「整條連線都收工了」的同義詞。在 FL 那個年代,因為讀取端夠快、poll_flush 幾乎總是當場就能完成、回 Ready,這個混淆從來不會顯現——write side 永遠在 read side 收工前就先一步完成了。一旦讀取端慢下來、poll_flush 開始回 Pending,被混為一談的兩個狀態才第一次分了岔,而那行 let _ 確保程式選了錯的那一邊。

loop 一旦判定收工,shutdown 就接著發生,連線被關,最後客戶端拿到的是「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。那行 end of file before message length reached 至此完全對上了:客戶端按 Content-Length 知道還該有 14.7 MB 沒到,卻先讀到了連線關閉的 EOF,於是報出「在 message length 達成前就遇到 EOF」。這也解釋了為什麼狀態碼是乾淨的 200——status line 與 header 早在 body 開始 flush 之前就送出去了,截斷只發生在 body 後段,HTTP 的回應狀態在語義上「已經成功」了。

那為什麼這個 bug 存在多年、偏偏現在才爆?答案在讀取端的速度。Cloudflare 的解釋是「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.」舊中介 FL 消費資料夠快,socket buffer 在一次回應期間幾乎不會滿;換成新 reader 後,它讀的速度偶爾會在較大回應期間讓 buffer 填滿——而 buffer 一滿,poll_flush 就回 Pending,那條早就埋好的錯誤路徑才被走到。Cloudflare 明說:「The December rearchitecture didn't introduce this bug, which had been present in hyper for years across multiple major versions.」這次重構沒有引入 bug,它只是把觸發條件變得夠常見。上面那個模擬裡,你把下游讀取速率拖低,就是在重現「新 reader 讀得慢一點」這件事。

四行修法,與它刻意不碰的地方

最直覺的修法是在 dispatch loop 裡把那個被丟掉的 Pending 接住:poll_flush 若回 pending 就讓整個 loop 也回 Poll::Pending、不要往下走到 shutdown。但 Cloudflare 沒有選這條。最終的修法下在更精準的位置:「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 的地方,在關 socket 之前先把 buffer 裡剩餘的資料 flush 掉。

具體就是讓 poll_shutdown 在發 SHUT_WR 之前先 ready!(self.poll_flush(cx)?):flush 若還沒完成,ready! 會直接讓 poll_shutdown 回傳 Pending、等下次再來,直到 buffer 清空才真正 shutdown。

// 最終修法:在 poll_shutdown 內,先 flush 再 SHUT_WR
pub(crate) fn poll_shutdown(
    &mut self, cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
    ready!(self.poll_flush(cx)?);   // flush 沒完成就回 Pending、等下次
    Pin::new(&mut self.io).poll_shutdown(cx)
}

為什麼不選 dispatch loop 那條更顯眼的修法?理由有兩層。Cloudflare 給的是設計上的:「This leaves the dispatch loop unchanged. It adds a flush only at the exact point where data loss would otherwise occur」,也就是 shutdown 之前那一刻。修法保持 dispatch loop 不動,只在「原本會發生資料遺失的那一刻」補一次 flush——把改動的 blast radius 壓到最小,不去動那段被多個程式碼路徑共用的核心迴圈。

另一層理由在 upstream PR #4018 的討論裡。這個 PR 的標題就是症狀本身:「h1 servers can shutdown connections with pending buffered data on filled sockets」——HTTP/1 server 會在 socket 填滿、buffer 還有未送資料時關掉連線。作者提到,他考慮過在 poll loop 內回傳 pending flush,但選了現在的做法。理由在這段討論裡也看得出來:在 loop 裡反覆回 Pending、若 waker 安排不當,是有 busy-spin、把 CPU 吃滿風險的;把 flush 收進 shutdown 這個只走一次的點,可以繞開那個風險。

這個取捨值得拆開看,因為它是底層網路程式庫常見的兩難。dispatch loop 是一段被多個程式碼路徑共用的核心,任何在它中段插入 early return 的改動,都要保證新加的 Pending 一定伴隨一個會被叫醒的 waker——否則 future 回了 Pending 卻沒人安排它重試,輕則卡死、重則因為立刻又被 poll 而 busy-spin 把 CPU 吃滿。要在共用迴圈裡把這件事做對,需要對整段控制流重新推理一遍,回歸測試的面也大。相對地,poll_shutdown 是一個語義單純、只在連線收尾時走一次的點:在這裡用 ready! 接住 flush 的 Pending,poll_shutdown 本來就會被 executor 在 socket 可寫時重新 poll,waker 的問題已經由 IO driver 處理好了。同樣是「flush 沒完成就回 Pending」,放在不同的位置,一個要連帶解決 CPU 與 waker,一個幾乎免費——這就是為什麼修法 B 只動四行,而修法 A 雖然更直觀卻被放棄。

這個四行修法是止血,不是終局。hyper 維護者 seanmonstar 在討論裡指出更乾淨的長期方向(原文用了 likely,屬建議):「the Write enum should likely get a new Flushing state, so that is_done isn't true until all expected bytes for a message are flushed.」——Write enum 應該多一個 Flushing 狀態,讓 is_done 在一則 message 所有預期 bytes 都 flush 完之前都不為 true。換句話說,從根上把「讀完 request」與「寫完 response」徹底分成兩個狀態,而不是靠在 shutdown 前補 flush 來繞過混淆。

這個建議的精神,正好對照出當前 bug 的結構性病灶。今天 hyper 的 write state 從 Writing 走到 Writing::Closed 就算「寫這一側的事做完了」,而 Closed 只代表「該寫的都已經交給了內部 buffer」,並不代表「buffer 裡的東西都已經離開了這台機器」。is_done 在這個定義下可能在資料還躺在 buffer 時就為 true。seanmonstar 提的 Flushing 狀態,就是在 Closed 與「真正完成」之間插進一個中繼態:只要還有 bytes 沒 flush 出去,狀態就停在 Flushingis_done 維持 false,shutdown 自然無從觸發。下面這張圖把現況與這個建議方向並排——左邊是今天會漏掉 flush 的兩態,右邊是補上中繼態之後的三態。

現況:兩態 Writing Writing::Closed is_done = true(但 buffer 可能還沒空) shutdown(SHUT_WR) buffer 未清 → 截斷 建議:多一個 Flushing Writing Flushing(新增) 還有 bytes 沒 flush → is_done = false Closed(buffer 已空) shutdown(資料已全送)
左為現況:Closed 即 is_done,buffer 未空也可能 shutdown。右為 seanmonstar 建議的方向:插入 Flushing 中繼態,buffer 清空前 is_done 不為 true。原文用 likely,屬建議。資料來源:hyperium/hyper PR #4018 討論。

下面這張表把受影響的版本、與兩種修法路徑並排起來。

面向 內容
受影響版本 實測 hyper 0.14、1.7、1.8 三版皆重現,bug 已跨多個大版本存在多年。
觸發條件 HTTP/1 server 回大 body,且下游讀取速度慢到讓 socket send buffer 在 flush 完成前填滿(backpressure)。
症狀 HTTP 200、無 error log、body 被截斷;client 端偶見 end of file before message length reached。
修法 A(未採用) 在 dispatch loop 內接住 poll_flush 的 pending、讓 loop 回 Poll::Pending。顯眼但動到共用迴圈,且有 busy-spin、耗 CPU 風險。
修法 B(採用) 在 poll_shutdown 內先 ready!(poll_flush) 再 SHUT_WR。dispatch loop 不動,只在 shutdown 前補 flush。以 PR #4018 合併。
長期方向 seanmonstar 建議 Write enum 增 Flushing state,讓 is_done 在所有 bytes flush 完前不為 true(原文 likely,屬建議)。
受影響版本與兩種修法路徑。最終採用的是 blast radius 最小的修法 B;資料來源:Cloudflare blog post 與 hyperium/hyper PR #4018。

Cloudflare 把修法與一個 deterministic test 一起送上游:「We merged our fix and the deterministic test into hyperium/hyper via PR #4018. It will be available in a future hyper release」——修法與測試已透過 PR #4018 合併進 hyperium/hyper,會出現在未來的 hyper release。對正在用 hyper 0.14 或 1.x 當 HTTP/1 server 的人來說,這意味著在那個 release 之前,只要你的下游消費速度可能慢於回應產生速度,silent truncation 在理論上就有可能發生。

對正在用 hyper 當 HTTP/1 server 的人,這裡有兩個可操作的判斷。其一,這個 bug 的觸發條件不挑業務邏輯,只挑時序:只要你回的 body 大到一次 write 塞不滿 socket、而下游消費的速度又可能慢於 hyper 產生資料的速度,理論上就有機會踩到。慢下游不必是異常——它可能只是一個忙碌的反向代理、一條壅塞的鏈路、或一個本身就在做 CPU 密集轉換的中介。其二,這個 bug 在三個大版本(0.14、1.7、1.8)上都在,所以「升級到最新版」在 fix 釋出之前並不能解決它;真正的緩解只有等帶 PR #4018 的 release,或在那之前自行 backport 那段 poll_shutdown 的 flush。在那之前,把它當成一個已知的 silent failure mode 放進心裡:200 不等於 body 完整。

這次獵 bug 留下兩個能直接帶走的判斷。一個是觀測層的盲點:「Application-level observability can have a blind spot for bugs that live below its awareness.」application 層的 observability 對活在它感知層之下的 bug 會有盲點——這次所有 application 工具都只能說「資料離開我這層時就壞了」,真正的突破來自 kernel 層,「Our breakthrough came from using kernel-level tooling with strace, the one layer that records what actually happened on the socket.」strace 是唯一記錄 socket 上真正發生什麼的一層。當監控全綠、症狀卻真實存在時,往下掉一層去看 syscall,往往比在原地加更多 application log 更快。

另一個更微妙:把系統變快,可能反而打開一扇原本關著的時間窗。Cloudflare 的結語是「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)。底層 bug 就活在 partial flush 與 premature shutdown 之間那幾毫秒。重構本身沒有引入缺陷,它只是改變了讀取端的時序,讓一條早就存在的錯誤路徑變得常被走到。效能優化改的不只是速度,還有所有與時序相關的邊緣條件——舊的 race 可能因此被觸發,也可能被掩蓋。

The lesson:當 200 OK 的 body 卻短少、application log 一片乾淨,別急著加更多 log——往下掉到 strace 那一層看 socket syscall;而每一次讓系統變快的重構,都要重新檢視它動到的時序邊緣,因為被掩蓋多年的 race,往往就在那幾毫秒裡重新打開。