Skip to content
Chris Yuan | Blog
Go back

macOS 上補 timeout 指令的正確做法:從「加 PATH」到「建 symlink」的除錯記錄

Edit page

Claude Code 這類 coding agent 在跑腳本時,很愛用 GNU coreutilstimeout 幫指令加上逾時保護,免得卡死。問題是 Apple 因為 GPL 授權的關係,macOS 內建的是 BSD 工具、沒有 timeout,得自己安裝。這篇記錄一次補 timeout 的除錯過程:從 Homebrew 官方建議的「把 gnubin 加進 PATH」,一路踩到 ls 顏色消失的副作用,再到最後用 symlink 收尾。中間還澄清了一個常見盲點:.zshrc 裡的 alias 和 function,其實沒辦法從 Makefile 開的子行程使用。

Table of contents

Open Table of contents

背景:timeout 是什麼,為什麼 macOS 上沒有

在某個專案裡跑 make build 時失敗,錯誤訊息是:

./scripts/safe-build.sh: line 77: timeout: command not found

原因很單純:scripts/safe-build.sh 裡用 timeout $MAX_BUILD_TIME npx next build 這種寫法來替建置流程加上逾時保護,但 macOS 內建的是 BSD 工具,沒有 timeout 這個指令,這是 GNU coreutils 才有的東西。

第一次嘗試:把整個 gnubin 塞進 PATH

我已經用 Homebrew 安裝了 coreutils 套件:

brew install coreutils

裝完後,Homebrew 會給出一段常見的 Caveats 提示:

Commands also provided by macOS and the commands dir, dircolors, vdir have been installed with the prefix "g".
If you need to use these commands with their normal names, you can add a "gnubin" directory to your PATH with:
  PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"

照這個提示,在 ~/.zshrc 加了一行:

export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"

這個目錄裡其實已經放好了一份「去掉 g 前綴」的 symlink(例如 timeout -> ../../bin/gtimeout),所以只要把它排到 PATH 前面,打 timeout(不用打 gtimeout)馬上就能用。實測也證實 make build 確實往下跑得更遠、不再卡在 timeout not found。

副作用:ls 的顏色不見了

看似解決問題,但後來發現:在 shell 下 ls 後,目錄/檔案類型顏色標示消失了。

根因分析:

Homebrew 官方 Caveats 建議的「把 gnubin 整個塞進 PATH」,看起來無害,實際上影響面很廣:它不是「多裝一個指令」,而是「把一整批系統指令換掉」,容易在不知情的狀況下產生看不出因果關係的副作用。

收斂方向:只包裝真正需要的那個指令

於是決定退回去,只針對 timeout 這一個指令做處理,.zshrc 裡的 gnubin PATH 改回註解掉:

# coreutils
#export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"

第一個念頭是寫一個 shell function:

timeout() { gtimeout "$@"; }

岔題:alias 還是 function 比較好?

在單純「原樣轉發參數」的情境下,兩種寫法效果幾乎一樣:

alias timeout=gtimeout
# vs
timeout() { gtimeout "$@"; }

技術上 function 略勝一籌:

真正的關鍵盲點:alias 和 function 都跳不出互動式 shell 的掌控

但這個 alias/function 之爭其實是個假議題,兩者都無法解決最初想解決的問題。

原因是:~/.zshrc 只會在「互動式 shell 啟動時」被 source。而 make build 實際上會呼叫:

scripts/safe-build.sh   # 檔案開頭是 #!/bin/bash

這是一個帶自己 shebang 的獨立腳本,執行時會開一個全新的 bash process,這個 process 完全不會去 source 我的 ~/.zshrc。不管是互動式定義的 alias 還是 function,都只存在於「定義它的那個 shell session」裡,不會被子行程繼承。

換句話說:

這一點很容易被忽略,卻直接決定了「這個修正到底有沒有解決原始問題」。

真正能同時滿足「互動式 shell 能用」與「非互動式子行程/腳本/Makefile 也能用」的做法,是在一個「已經在 PATH 上、且不會整批覆蓋其他指令」的目錄裡,只放這一個指令的 symlink:

mkdir -p ~/.local/bin
ln -sf "$(command -v gtimeout)" ~/.local/bin/timeout

(這裡 command -v gtimeout 假設 Homebrew 的 bin 已經在 PATH 上;若沒有,把目標寫死成 "$(brew --prefix)/bin/gtimeout" 會更保險,免得展開成空字串、建出一個指向空路徑的壞 symlink。)

~/.local/bin 只要已經在 PATH 上就不用再改設定——我的 .zshrc 本來就有這一行。要注意的是 macOS 預設並不會把 ~/.local/bin 放進 PATH,如果你的環境還沒有,得先自己加一行:

export PATH="$HOME/.local/bin:$PATH"

而這一步之所以能讓子行程也吃到 timeout,靠的正是 PATH 會被匯出、由子行程繼承,而不是子行程回頭去 source 你的 .zshrc。這個方式:

同時把 .zshrc 裡先前加的 function 拿掉,改成註解說明原因:

# coreutils (GNU) — wrap only the specific commands needed instead of
# prepending gnubin to PATH, which silently replaces ls/dir/vdir with the
# GNU versions and breaks macOS ls color highlighting (LSCOLORS vs LS_COLORS).
# `timeout` is provided via a ~/.local/bin/timeout symlink to gtimeout instead
# of an alias/function, so it's also visible to non-interactive child scripts
# (e.g. Makefile recipes) that don't source this file.
#export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"

驗證

分兩種情境驗證,確保兩邊都吃得到。

互動式 shell:

$ zsh -ic 'command -v timeout; type timeout; timeout 1 sleep 2; echo "exit code: $?"'
/Users/chrisyuan/.local/bin/timeout
timeout is /Users/chrisyuan/.local/bin/timeout
exit code: 124

模擬非互動式子行程(乾淨環境、不 source 任何 rc 檔):

$ env -i PATH="$HOME/.local/bin:/usr/bin:/bin:/opt/homebrew/bin" bash -c \
    'command -v timeout; timeout 1 sleep 2; echo "exit code: $?"'
/Users/chrisyuan/.local/bin/timeout
exit code: 124

兩者都正確回傳 exit code 124(逾時),確認 timeout 在任何呼叫情境下都能正常運作。

結論:只需要一兩個指令時,別整批覆蓋

  1. Homebrew 的「把 gnubin 加進 PATH」是圖方便的整批方案,代價是覆蓋一整組同名系統指令,在 macOS 上容易產生像 ls 顏色消失這種不易聯想到根因的副作用。只要不是真的想全面切換成 GNU 工具鏈,就不該這樣用。
  2. 只需要一兩個 GNU 指令時,該做的是「選擇性放行」,而不是「整批覆蓋」,可以用 alias、function,或(更好)symlink 到一個乾淨的個人 bin 目錄。
  3. Alias 和 function 都只是互動式 shell 內部的語法糖,不會被子行程繼承。如果目標包含「讓 Makefile、腳本、CI 這類非互動式流程也能用到這個指令」,就必須讓它變成 PATH 上一個真實存在的檔案(執行檔或 symlink),這是唯一在所有呼叫情境下都成立的做法。
  4. 這個案例也說明「debug 時看起來解決了」不等於「真的解決了根本問題」。make build 有繼續往下跑,並不代表修正在所有情境下都站得住;如果沒有進一步確認子行程的 shell 語意,很容易在 alias/function 這層就誤以為大功告成。

Edit page
Share this post on:Link copied!

Previous Post
GitHub Actions 自動部署靜態網站到 Cloudflare Pages:推送即上線
Next Post
反正我是信了:一段歷史和約條文中的「視為」發揮什麼作用