Skip to content

codex-call SSE 分幀與終端事件語意:byte 邊界丟包、殘留 buffer 不 flush、終端事件政策缺證據 #28

Description

@kiki830621

Problem

bin/codex-call 的 SSE 分幀與終端事件處理有一組互相關聯的缺陷。它們不是獨立 bug,是同一個子系統的同一組問題,且共同缺少同一份證據:沒有人觀察過後端 teardown 的實際行為。

本 issue 自 #25 拆出。#25 的原始症狀(extractErrorMessage 漏了 json["error"]["message"] 路徑)是可證偽、有實測 payload、one-line 可修的;本 issue 承接的則是政策性問題 —— #25 在三輪 verify 中兩度嘗試順帶解決,兩度被推翻。

Type

bug

缺陷清單(皆有 verify 實測佐證)

1. byte-level 分幀:UTF-8 切在字中間 → 整個 chunk 被丟棄

guard let chunk = String(data: data, encoding: .utf8) else { return }   // bin/codex-call

URLSessiondidReceive data: 按 TCP 可用量交付,與 UTF-8 codepoint 邊界零對齊保證。切點落在多位元組字元中間時 String(data:encoding:.utf8)nil(Foundation 不做 U+FFFD 替換),guard else { return }整個 chunk 連同其中所有完整事件一起丟棄,且 bytes 不保留、不與下一 chunk 重組 —— 下一 chunk 開頭成為孤兒 continuation bytes,同樣整塊丟掉。

verify 實測(/usr/bin/swift):

cut at byte 46 (continuation byte) — chunk1=46B chunk2=34B
String(data: chunk1, .utf8) = nil  <-- 整個 chunk 被 guard 丟棄
String(data: chunk2, .utf8) = nil  <-- 整個 chunk 被 guard 丟棄

這被本 plugin 的預設放大codex-call 預設 --instructions 是「Respond in the user's language」,workflows/ensemble-workflow.jscodexPrompt() 更明確要求繁體中文輸出 —— 所有 delta 與所有後端錯誤訊息都是 3-byte CJK,切在字中間是常態不是邊角。

後果兩種,都靜默:

  • 掉的是 response.output_text.deltaaccumulated 少一段、streamError 仍 nil → 寫出截斷的 review 並 exit 0。末端的 fail-closed 只擋 accumulated 完全空(code 422),擋不住部分遺失 → ensemble 把腰斬的跨模型審閱當完整票採計
  • 掉的是終端 error 事件 → 錯誤本身消失,最後以 422「no text output」歸因,把後端過載誤報成模型沒輸出

修法方向:以 Data 累積 buffer、在 byte 層找 \n\n,或保留無法解碼的尾段與下一 chunk 重組。

2. 殘留 buffer 從不 flush:未終結的最後一個事件被丟棄

drain loop 只處理有完整 \n\n 分隔的事件;didCompleteWithError 從不碰 buffer。後端送出 error 後直接關閉 socket(可能不送 trailing 空行)時,該事件永遠不被處理。

verify 實測:

$ codex-call --selftest-process-events '["data: {...delta...}\n\ndata: {"type":"error",...,"message":"Our servers are currently overloaded."}"]'
(no error)

對照組(同輸入 + trailing \n\n)正確印出訊息。

後果鏈:streamError 保持 nil → 不 throw → accumulated 非空 → 寫出截斷內容 → exit 0 = forged vote。

修法方向:didCompleteWithError 在 200 路徑上先處理殘留 buffer 再 signal。

3. 「哪個終端事件勝出」缺乏證據,且兩種嘗試都失敗

#25 在 R1 與 R2 分別試過兩種政策,都被 verify 推翻:

嘗試 判準 推翻理由
R1:first-wins latch if streamError == nil arrival-order 政策。實測四交錯後證明它只是把失敗從 D 搬到 B(B 在修法前是好的),不是消除
R2:informativeness streamErrorIsFallback && msg != CODEX_FALLBACK_MESSAGE 只是 1-bit 投影。bare↔rich 確實 order-independent,但 rich↔rich 退回純 arrival-order —— 正是 R1 被判定「押一個沒有證據的排序」的那個 heuristic

而後端 teardown 的真實形狀恰恰落在 rich↔rich:{"type":"error","error":{"code":"server_is_overloaded","message":…}}#25 實測 payload)與 {"type":"response.failed","response":{"error":{"message":…}}} 兩者都帶 message。哪個更該呈現給使用者?至今沒有證據。

附帶缺陷(同一個病的不同症狀,非獨立 bug):

  • 空字串 / 純空白 message 鎖死:判準是與 sentinel 字面值比對,空字串通過 → 標記為非 fallback → 永久阻擋升級。實測輸出空行且真訊息被丟棄。同一個洞也在 extractErrorMessage 內(top-level 空 message 遮蔽 nested 真訊息)
  • 後端真的送 "Codex error" 會被誤判為 fallback

修法方向:換成對訊息本身求值的謂詞(trim 後非空、長度/內容有意義),而非與字面值比較。但這仍不回答 rich↔rich 該選誰 —— 那需要先補 trace。

4. didCompleteWithError 的同址政策未統一

} else if let err = error, streamError == nil {
    streamError = err
}

這是 first-wins arrival-order latch。可達交錯:HTTP 200 → 一個 bare 終端事件抵達(streamError = fallback)→ socket 死掉 → didCompleteWithError 收到真實 NSURLError(「The network connection was lost.」)→ 因 streamError != nil丟棄 transport error,使用者看到 Codex error

「後端送 code-only error 然後斷線」正是 #25 那次事故最可能的形狀。

5. CRLF-framed SSE 永遠不產生事件邊界

分隔符硬編碼 \n\n。若後端改送 \r\n\r\n,buffer 無上限成長且每個事件(含終端事件)都不會被處理

為什麼要先補 trace 再定政策

第 3 項的核心問題是「多個終端事件時哪個勝出」,而這個問題在三輪 verify 中始終缺同一份證據:

  • 後端 teardown 到底會不會送 ≥2 個終端事件?
  • 若會,順序如何?
  • 兩個都帶 message 時,哪個比較完整?

#25 的 R2 曾為此寫了 9 個測試 case 去釘住一個自承無證據的偏好 —— 其中「兩個 rich:先到者保留」把未定義行為升級成有測試保護的契約,使得日後拿到真實 trace 想改對時,會被自己的測試擋下、且看起來像「破壞既有行為」。

先補 trace(開啟 raw SSE 記錄、蒐集數次真實 teardown),再定政策,才不會重演。

Impact

  • 靜默的截斷輸出 + exit 0 = ensemble 採計不完整的跨模型票(forged vote)
  • 後端過載被誤報成「模型沒輸出」,診斷方向被帶偏
  • 中文輸出情境下 byte 邊界問題是常態,非邊角

Strategy(待 diagnose 確認)

  • 先補 trace:加一個可選的 raw SSE dump(env var 或 hidden flag),蒐集數次真實後端 teardown
  • byte-level buffering:改以 Data 累積、byte 層找分隔符,或保留無法解碼的尾段
  • didCompleteWithError flush 殘留 buffer(200 路徑)
  • 依 trace 證據定義終端事件政策;判準改為對訊息求值的謂詞
  • 統一 didCompleteWithErrorprocessEvent 的政策
  • CRLF 分隔符支援
  • 測試改以 byte 序列表達邊界(現有的 [String] hook 在構造上表達不出危險的那一半)

#25 的關係

#25 保留有實測錨點的部分(extractErrorMessage 補漏路徑 + 5 case + metadata),本 issue 承接政策性與分幀問題。切分理由:證據強度不同的東西不該綁在同一個交付單元 —— #25 的症狀有可重現的 payload,本 issue 的核心問題至今無 trace。


Source: surfaced during /idd-verify #25 --pr 26 rounds 1–3(三輪 FAIL 累積;R3 的 devil's-advocate 明確建議此切分)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions