目次
はじめに
Bash スクリプトでインタラクティブなメニューを作りたいとき、read
コマンドを使ってキー入力を処理する方法があります。
しかし、実際に矢印キーで操作できるメニューを作ってみると、
上下キーを押した瞬間にスクリプトが終了してしまうことがあります。
この記事では、この現象の再現方法と原因、そして解決方法を紹介します。
問題の再現
以下のサンプルコードを実行して、上下キーを押してみてください。
ファイル名: test_menu_bug.sh
#!/usr/bin/env bash
set -e
set -o pipefail
# 再現用:結果を echo で返す版(nameref 不使用)
# ※ read -t のタイムアウトや、呼び出し側のコマンド置換が set -e と噛み合って落ちます
capture_keys() {
local a b c
IFS= read -rsn1 a
if [[ -z $a ]]; then
echo "Enter"
return
fi
if [[ $a != $'\x1b' ]]; then
echo "$a"
return
fi
# ★ NGポイント1: タイムアウトして終了コード1になる可能性(set -e で即終了)
read -rsn1 -t 0.05 b
if [[ $b == "[" || $b == "O" ]]; then
# ★ NGポイント2: ここも同様にタイムアウトで終了コード1 → 即終了
read -rsn1 -t 0.05 c
case "$c" in
A) echo "Up" ;;
B) echo "Down" ;;
C) echo "Right" ;;
D) echo "Left" ;;
*) echo "Unknown" ;;
esac
else
echo "Escape"
fi
}
show_test_menu() {
echo "テスト用メニュー(矢印キーで止まる再現コード)"
echo "矢印キーで選択、Enterで決定"
echo ""
local options=("オプション1" "オプション2")
local selected=0
local key=""
while true; do
for i in "${!options[@]}"; do
if [[ $i -eq $selected ]]; then
printf "\e[32m> %s\e[0m\n" "${options[$i]}"
else
printf " %s\n" "${options[$i]}"
fi
done
# ★ NGポイント3: コマンド置換 $(...) 内で read 実行 → set -e の例外規則が効かず落ちやすい
key="$(capture_keys)"
# 表示クリア
for ((i=0; i<${#options[@]}; i++)); do
printf "\e[1A\e[2K"
done
case "$key" in
Up)
# ★ NGポイント4: 単独 (( ... )) は式が0だと終了コード1 → set -e で終了することがある
((selected--))
if [[ $selected -lt 0 ]]; then
selected=$((${#options[@]} - 1))
fi
;;
Down)
((selected++))
if [[ $selected -ge ${#options[@]} ]]; then
selected=0
fi
;;
Enter)
for i in "${!options[@]}"; do
if [[ $i -eq $selected ]]; then
printf "\e[32m> %s\e[0m\n" "${options[$i]}"
else
printf " %s\n" "${options[$i]}"
fi
done
break
;;
*)
:
;;
esac
done
echo ""
echo "選択されたオプション: ${options[$selected]}"
}
show_test_menu
症状
- 上下矢印キーを押すと「選択されたオプション: …」が表示される前に終了してしまう
set -e
を外すと正常に動作する
原因
この現象の原因は、set -e
と read -t
の組み合わせにあります。
set -e
(errexit)は「終了コードが非ゼロのコマンドでスクリプトを強制終了」するオプションread -t
は「タイムアウトしたときに終了コード1を返す」
つまり、矢印キーの入力を待っている間に read -t
がタイムアウトすると、
正常なタイムアウト処理なのにエラー扱いされてスクリプトが終了してしまうわけです
解決方法
ポイント
- エスケープシーケンスが終わらなかった場合でも「Escape」として処理すれば止まらない
read -t
を必ずif ...; then ... fi
でラップして「タイムアウトを正常系として扱う」
修正版コード(成功例)
ファイル名: test_menu_fixed.sh
#!/usr/bin/env bash
set -e
set -o pipefail
capture_keys() {
local -n __out=$1
__out=""
local a b c
if ! IFS= read -rsn1 a; then
__out=""
return 0
fi
if [[ -z $a ]]; then
__out="Enter"
return 0
fi
if [[ $a != $'\x1b' ]]; then
__out="$a"
return 0
fi
if IFS= read -rsn1 -t 0.05 b; then
if [[ $b == "[" || $b == "O" ]]; then
if IFS= read -rsn1 -t 0.05 c; then
case "$c" in
A) __out="Up" ;;
B) __out="Down" ;;
C) __out="Right" ;;
D) __out="Left" ;;
*) __out="Unknown" ;;
esac
else
__out="Escape"
fi
else
__out="Escape"
fi
else
__out="Escape"
fi
return 0
}
show_test_menu() {
echo "テスト用メニュー"
echo "矢印キーで選択、Enterで決定"
echo ""
local options=("オプション1" "オプション2")
local selected=0
local key=""
while true; do
for i in "${!options[@]}"; do
if [[ $i -eq $selected ]]; then
printf "\e[32m> %s\e[0m\n" "${options[$i]}"
else
printf " %s\n" "${options[$i]}"
fi
done
capture_keys key
for ((i=0; i<${#options[@]}; i++)); do
printf "\e[1A\e[2K"
done
case "$key" in
Up)
selected=$((selected - 1))
if [[ $selected -lt 0 ]]; then
selected=$((${#options[@]} - 1))
fi
;;
Down)
selected=$((selected + 1))
if [[ $selected -ge ${#options[@]} ]]; then
selected=0
fi
;;
Enter)
for i in "${!options[@]}"; do
if [[ $i -eq $selected ]]; then
printf "\e[32m> %s\e[0m\n" "${options[$i]}"
else
printf " %s\n" "${options[$i]}"
fi
done
break
;;
*)
:
;;
esac
done
echo ""
echo "選択されたオプション: ${options[$selected]}"
}
show_test_menu
実行結果
- 上矢印キー →
Up
- 下矢印キー →
Down
- Enterキー →
Enter
- ESCキー →
Escape
スクリプトも最後まで実行され、最後に「選択されたオプション: …」が表示されます。
おまけ:便利Tips
エラーを常に表示する trap ERR
set -e
でスクリプトが落ちると「なぜ?」と原因がわかりにくい場合があります。
そんなときは冒頭に trap ERR
を仕込むと便利です。
これでエラー発生時に 赤字でコマンドと終了コードを表示してくれるので、デバッグが楽になります。
trap 'echo -e "\e[31mERR: \"$BASH_COMMAND\" exited with code=$?\e[0m" >&2' ERR
まとめ
- 症状: 矢印キーを押すと「選択されたオプション」が出る前にスクリプトが終了する
- 原因:
set -e
とread -t
の組み合わせ。read -t
のタイムアウトが終了コード1を返す →set -e
で即終了 - 解決方法:
if read ...; then ... else ... fi
でラップし、タイムアウトを正常処理扱いにする - 結果: 修正版では Up/Down/Enter/Escape が正常に判定され、最後に「選択されたオプション: …」が表示される