包包baobaolin.com
date
entry
025
topic
tooling
rev
—

codex exec 卡住不動:它在等 stdin 的 EOF

同一行 codex exec,stdin 立刻結束時 11 秒回來,從另一個程式呼叫就一直掛著。它在等 stdin 的寫入端關閉,而沒有人會關。

我常讓 Codex 的命令列 codex exec 做第二輪查核:一個 prompt 丟進去,它讀檔、回一份報告。一個只要它回「OK」的簡短 prompt,在 stdin 立刻結束的情況下,11 秒就回來。

同一行指令從另一個程式裡呼叫,它可以一直掛著:stdout 沒有回覆,stderr 停在一行讀取提示。這篇要談的是它在等什麼:stdin(標準輸入)的 EOF(輸入結束)。依序回答三件事:機制是什麼、哪些情境會踩到(其中一種不會卡住,而是悄悄吃掉別人的資料)、怎麼關掉。

機制:prompt 之外,它還會讀 stdin

codex exec --help(codex-cli 0.159.2)對 PROMPT 參數的說明:

If stdin is piped and a prompt is also provided, stdin is appended as a <stdin> block

也就是說,在已經給了 prompt、而 stdin 不是終端機的情況下,它會讀 stdin 讀到 EOF,把讀到的內容接在 prompt 後面(沒給 prompt 或給 - 時,stdin 就是整個 prompt)。可以直接驗證:

$ echo "The secret word is PAPAYA." | codex exec --skip-git-repo-check -s read-only \
    "What is the secret word? Reply with the word only."
PAPAYA

stderr 的紀錄裡,prompt 後面多了一段:

<stdin>
The secret word is PAPAYA.
</stdin>

這是一個方便的功能。問題出在「讀到 EOF 為止」:pipe 的寫入端只要還開著,即使一個位元組都沒寫,讀取端就到不了 EOF。stderr 會留下 Reading additional input from stdin...,然後就停在那裡。

情境一:pipe 開著,它就等著

用同一個 prompt(Reply with exactly: OK)試四種 stdin,各跑一次,時間是從開始到程序結束:

stdin結果
< /dev/null11 秒,回 OK
sleep 30 |(pipe 開 30 秒)38 秒,回 OK
Node.js spawn(),不關 stdin60 秒時仍在執行,stdout 是空的,由腳本砍掉
Node.js spawn(),接著 child.stdin.end()9.6 秒,回 OK

第二列比第一列多了 27 秒,符合「等上游那 30 秒的 sleep 結束、pipe 關閉」的解釋;單次的總時間也包含啟動與模型處理,所以沒辦法精確拆出等待了幾秒。第三列是從程式呼叫時的卡法:Node.js 文件寫明 spawn() 的 options.stdio 預設是 'pipe',stdin、stdout、stderr 都會在父子程序之間建一條 pipe。父程序還活著、又沒有結束 stdin 的寫入端,子程序就等不到 EOF。各種 agent 工具、編輯器外掛、CI runner 用程式啟動子程序時,stdin 給的是什麼取決於它們怎麼寫,光看那行指令看不出來。

重現用的程式(存成 repro.mjs;node repro.mjs 不關 stdin,45 秒時仍停在讀取提示;node repro.mjs end 會關,11 秒印出 OK):

import { spawn } from "node:child_process";

const child = spawn("codex", ["exec", "--skip-git-repo-check", "-s", "read-only",
                              "Reply with exactly: OK"],
                    { stdio: ["pipe", "inherit", "inherit"] });
if (process.argv[2] === "end") child.stdin.end();
child.on("close", (code) => { process.exitCode = code ?? 1; });

情境二:它不卡,但吃掉迴圈的輸入

stdin 有東西、而且會結束的時候,它不會卡,會照單全收。這在 while read 迴圈裡是另一種問題。三個 slug 放在檔案裡,逐一交給 codex:

$ printf 'alpha\nbeta\ngamma\n' > slugs.txt
$ n=0; while read -r slug; do n=$((n+1)); echo "iteration $n: $slug"
    codex exec --skip-git-repo-check -s read-only -o "D$n.last" \
      "Reply with exactly the word: $slug" > "D$n.out" 2> "D$n.err"
    echo "  codex said: $(cat "D$n.last")"
  done < slugs.txt; echo "loop ran $n times"
iteration 1: alpha
  codex said: alpha
loop ran 1 times

迴圈只跑了一圈。剩下兩行去了哪裡,codex 的紀錄寫得很清楚:

<stdin>
beta
gamma
</stdin>

迴圈裡的 codex 沒有另外指定 stdin,於是繼承了迴圈的 stdin,也就是 slugs.txt 剩下的內容。codex 把它讀完當成 prompt 的附件,read 下一次就讀到 EOF,迴圈結束,沒有任何錯誤訊息。推到一百篇文章的清單上(沒有實測),迴圈同樣只會呼叫一次 codex,而它照常結束,看起來像全部跑完。

這和 ssh 是同一類坑。man ssh 對 -n 的說明是「讓 stdin 從 /dev/null 讀取(實際上是不讀 stdin)」,並說在背景執行 ssh 時一定要用。

修法:給它一個立刻結束的 stdin

同一個迴圈,codex 那行加上 < /dev/null,輸出改成一行一圈:

$ n=0; while read -r slug; do n=$((n+1))
    codex exec --skip-git-repo-check -s read-only -o "E$n.last" \
      "Reply with exactly the word: $slug" < /dev/null > "E$n.out" 2> "E$n.err"
    echo "iteration $n: $slug -> $(cat "E$n.last")"
  done < slugs.txt; echo "loop ran $n times"
iteration 1: alpha -> alpha
iteration 2: beta -> beta
iteration 3: gamma -> gamma
loop ran 3 times

從程式呼叫時,對應的寫法是:

  • Node.js:spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] })(stdin 接到 /dev/null),或在預設 pipe 的情況下啟動後立刻 child.stdin.end()
  • Python:subprocess.run(..., stdin=subprocess.DEVNULL)

我現在把 < /dev/null 寫進每一個 codex exec 的範本,包括交給 agent 的指示。prompt 已經用參數給完、任務又不需要 stdin 的時候,會不會因為等 EOF 而卡住取決於呼叫者的 stdin,而呼叫者是誰,寫指令的時候不一定知道。

如果只記得一件事

「沒有寫東西進去」和「stdin 已經到 EOF」是兩回事。開頭那個掛住的 codex exec 在等寫入端關閉,而沒有人會關。說明文件裡出現「if stdin is piped」的命令列工具,任務不需要 stdin 時,在腳本裡呼叫都值得補上 < /dev/null。

修訂紀錄

  1. 首次發布