- 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/null | 11 秒,回 OK |
sleep 30 |(pipe 開 30 秒) | 38 秒,回 OK |
Node.js spawn(),不關 stdin | 60 秒時仍在執行,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。
修訂紀錄
- 首次發布