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
URLSession 的 didReceive 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.js 的 codexPrompt() 更明確要求繁體中文輸出 —— 所有 delta 與所有後端錯誤訊息都是 3-byte CJK,切在字中間是常態不是邊角。
後果兩種,都靜默:
- 掉的是
response.output_text.delta → accumulated 少一段、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 確認)
#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 明確建議此切分)
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 被丟棄
URLSession的didReceive 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):這被本 plugin 的預設放大:
codex-call預設--instructions是「Respond in the user's language」,workflows/ensemble-workflow.js的codexPrompt()更明確要求繁體中文輸出 —— 所有 delta 與所有後端錯誤訊息都是 3-byte CJK,切在字中間是常態不是邊角。後果兩種,都靜默:
response.output_text.delta→accumulated少一段、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 實測:
後果鏈:
streamError保持 nil → 不 throw →accumulated非空 → 寫出截斷內容 → exit 0 = forged vote。修法方向:
didCompleteWithError在 200 路徑上先處理殘留buffer再 signal。3. 「哪個終端事件勝出」缺乏證據,且兩種嘗試都失敗
#25 在 R1 與 R2 分別試過兩種政策,都被 verify 推翻:
if streamError == nilstreamErrorIsFallback && msg != CODEX_FALLBACK_MESSAGE而後端 teardown 的真實形狀恰恰落在 rich↔rich:
{"type":"error","error":{"code":"server_is_overloaded","message":…}}(#25 實測 payload)與{"type":"response.failed","response":{"error":{"message":…}}}兩者都帶 message。哪個更該呈現給使用者?至今沒有證據。附帶缺陷(同一個病的不同症狀,非獨立 bug):
extractErrorMessage內(top-level 空 message 遮蔽 nested 真訊息)"Codex error"會被誤判為 fallback修法方向:換成對訊息本身求值的謂詞(trim 後非空、長度/內容有意義),而非與字面值比較。但這仍不回答 rich↔rich 該選誰 —— 那需要先補 trace。
4.
didCompleteWithError的同址政策未統一這是 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 中始終缺同一份證據:
#25 的 R2 曾為此寫了 9 個測試 case 去釘住一個自承無證據的偏好 —— 其中「兩個 rich:先到者保留」把未定義行為升級成有測試保護的契約,使得日後拿到真實 trace 想改對時,會被自己的測試擋下、且看起來像「破壞既有行為」。
先補 trace(開啟 raw SSE 記錄、蒐集數次真實 teardown),再定政策,才不會重演。
Impact
Strategy(待 diagnose 確認)
Data累積、byte 層找分隔符,或保留無法解碼的尾段didCompleteWithErrorflush 殘留 buffer(200 路徑)didCompleteWithError與processEvent的政策[String]hook 在構造上表達不出危險的那一半)與 #25 的關係
#25 保留有實測錨點的部分(
extractErrorMessage補漏路徑 + 5 case + metadata),本 issue 承接政策性與分幀問題。切分理由:證據強度不同的東西不該綁在同一個交付單元 —— #25 的症狀有可重現的 payload,本 issue 的核心問題至今無 trace。Source: surfaced during
/idd-verify #25 --pr 26rounds 1–3(三輪 FAIL 累積;R3 的 devil's-advocate 明確建議此切分)