Noraworld Developers Blog
シェルでコマンドを実行中は Mac をスリープさせないようにする方法

:warning: この記事は macOS 専用です。

:information_source: Linux でも、macOS の caffeinate コマンドと同等のコマンドがあれば使えるかもしれません。もしご存じの方がいたらコメントで教えていただけるとありがたいです。

はじめに

シェルを操作しているときに、たまに時間がかかるコマンドを実行することがあると思います。

その際にコマンド終了まで放置していると Mac がスリープしてしまうことがあります。

もしかしたらシステムはスリープしていなくて、ディスプレイだけがスリープしているのかもしれませんが、ディスプレイがスリープしていると、コマンドが中断されてしまっているのではないかと不安になりますし、ときおりコマンドの進捗状況を確認したくてもスリープしていてわからないことがあります。

そこで、コマンド実行中は Mac がスリープしない (ディスプレイのスリープも含む) ようにする方法を紹介します。

環境

  • macOS Big Sur 11.1
  • Zsh 5.8

設定方法

以下を ~/.zshrc に貼り付けます。

:warning: ~/.zshrc の該当部分だけを抜き出して貼り付けているので、下記の ~/.zshrc 単体での動作は確認していません。もし正常に動作していなければコメントで教えてください。

_tn_cmd=''
need_caffeine=true
_tn_caffeinate_pid=''

preexec() {
  _tn_cmd=$1
  need_caffeine=true

  while read line
  do
    if [[ $_tn_cmd =~ ^([[:blank:]]+.*)*$line([[:blank:]]+.*)*$ ]]; then
      need_caffeine=false
    fi
  done < ~/.decaffeinated_command_list

  if type caffeinate 1>/dev/null 2>/dev/null && "${need_caffeine}"; then
    (caffeinate -d & echo $!) | read _tn_caffeinate_pid
  fi
}

precmd() {
  if "${need_caffeine}" && [[ "$_tn_caffeinate_pid" =~ ^[0-9]+$ ]]; then
    kill "$_tn_caffeinate_pid"
  fi
  _tn_caffeinate_pid=''
}

次に、~/.decaffeinated_command_list というファイルを作ります。

$ touch ~/.decaffeinated_command_list

そして、~/.decaffeinated_command_list に以下を書き込みます。

exit
exec
source
script

とりあえず設定方法だけ知りたい方はこれで終わりです。

解説

ここから先は具体的な解説をします。

caffeinate コマンド

Mac には caffeinate というコマンドが用意されています。このコマンドを実行すると Mac をスリープさせないようにすることができます。

caffeinate コマンドのオプション

| オプション | 効果 | 備考 | |—|—|—| | -d | ディスプレイをスリープさせないようにします || | -i | システムをアイドル状態にさせないようにします || | -m | ディスクをスリープさせないようにします || | -s | システムをスリープさせないようにします | MacBook の場合、電源に接続されているときのみ有効 |

今回はディスプレイをスリープさせたくないので、-d を指定します。

preexec()precmd()

~.zshrcpreexec() という関数を用意すると、何かしらのコマンドの実行前に preexec() の中身が実行されます。

また、precmd() という関数を用意すると、コマンドの終了後 (厳密には新しいプロンプトが表示される直前) に precmd() の中身が実行されます。

これを利用して、インタラクティブシェルでコマンド実行前に caffeinate コマンドをバックグラウンドで実行しディスプレイのスリープを抑制し、コマンド終了後に caffeinate コマンドのプロセスを kill します。

~/.decaffeinated_command_list について

~/.decaffeinated_command_list というファイルを作りましたが、これは caffeinate コマンドを実行しないコマンドのリストです。

~/.decaffeinated_command_list を再掲します。

exit
exec
source
script

たとえば、exit コマンドがこのリストの中に入っているので、インタラクティブシェルで exit コマンドを実行したときは caffeinate コマンドは実行されないというわけです。

こうしておかないと caffeinate コマンドのプロセスが残り続ける (ずっとスリープしなくなる) ことになります。

exit コマンドでシェルを終了したり、exec コマンドや source コマンドでシェルや ~/.zshrc を再読み込みしたりした場合、コマンド実行前に preexec() の中身は実行されますが、precmd() の中身は実行されません。

ということは、preexec() が実行されて caffeinate コマンドが実行されるのは良いものの、precmd() が実行されないので caffeinate コマンドのプロセスが残り続けてしまいます。こうすると不眠不休の Mac になってしまいます。

それを避けるために、preexec() は実行されるが precmd() が実行されないようなコマンドは ~/.decaffeinated_command_list に入れておくことをおすすめします。

なお、上記の理屈により、ssh で別のマシンにログイン中も Mac はスリープしません。SSH 先では caffeinate コマンドが使えないので、SSH 接続中はずっとスリープしないようにしても良いと個人的には思っていますが、これが気に入らない場合は ssh~/.decaffeinated_command_list に追加してください。

他にも、manlessvim を開きっぱなしにして放置している間にスリープしてほしい場合は、それらを ~/.decaffeinated_command_list に追加してください。

$! について

(caffeinate -d & echo $!) | read _tn_caffeinate_pid

$! にはバックグラウンドプロセスのうち、直前に実行されたプロセスのプロセス番号が入っています。

上記のスクリプトでは、caffeinate -d &caffeinate コマンドがバックグラウンドで起動したので、$! には caffeinate コマンドのバックグラウンドプロセスのプロセス番号が入っています。

そのプロセス番号を _tn_caffeinate_pid に代入しています。コマンド終了時に precmd() でこのプロセス番号を指定して kill しています。

pkill caffeinate ではダメな理由

プロセス番号を保持しなくても pkill caffeinate を使えば caffeinate コマンドのプロセスを kill することはできます。

しかしそれだと、別のシェルセッションで実行中の caffeinate コマンドのプロセスも一緒に kill されてしまいます。

たとえばシェルセッションを 2 つ起動していて、片方で時間がかかるコマンドを実行させていて、もう片方で別の作業をしていたとします。

もし pkill caffeinate を使っていた場合、作業している側のシェルで caffeinate コマンドのプロセスをすべて kill してしまっているので、もう片方の、時間がかかるコマンドがまだ終了していなくてもスリープしてしまいます。

作業の合間に休憩しようとしてスリープしてしまうことになります。

それを防ぐために、ちゃんとプロセス番号を保持して、そのコマンドを実行したときの caffeinate コマンドのプロセスだけを kill するようにします。

おまけ: caffeinate コマンドの起動と終了のチェック

コマンド実行前に、本当に caffeinate コマンドが実行されるのかどうか、そして終了後にちゃんとプロセスが kill されているのかが気になるかもしれません。

その場合は以下を ~/.zshrc に追記します。

+ export PROMPT_STATE=""
+
+ caffeine_count() {
+   echo -e "\033[38;05;172m$(ps aux | grep caffeinate | grep -cv grep)\033[00m"
+ }
+
+ get_prompt_state() {
+   PROMPT_STATE="☕️ $(caffeine_count)"
+ }
+ get_prompt_state

  _tn_cmd=''
  need_caffeine=true
  _tn_caffeinate_pid=''

  preexec() {
    _tn_cmd=$1
    need_caffeine=true

    while read line
    do
      if [[ $_tn_cmd =~ ^([[:blank:]]+.*)*$line([[:blank:]]+.*)*$ ]]; then
        need_caffeine=false
      fi
    done < ~/.decaffeinated_command_list

    if type caffeinate 1>/dev/null 2>/dev/null && "${need_caffeine}"; then
      (caffeinate -d & echo $!) | read _tn_caffeinate_pid
    fi
+
+   if "${need_caffeine}"; then
+     if ! { [ "$(ps aux | grep caffeinate | grep "$_tn_caffeinate_pid" | grep -cv grep)" -eq 1 ] && [[ "$_tn_caffeinate_pid" =~ ^[0-9]+$ ]]; } then
+       echo -e "\033[1;93mWARNING:\033[00m Running out of caffeine! Computer may sleep while executing \`\033[1m$_tn_cmd\033[00m' if it takes long time\033[00m"
+       echo
+     fi
+   fi
  }

  precmd() {
    if "${need_caffeine}" && [[ "$_tn_caffeinate_pid" =~ ^[0-9]+$ ]]; then
      kill "$_tn_caffeinate_pid"
    fi
    _tn_caffeinate_pid=''
+
+   get_prompt_state
  }

preexec() では、以下のいずれかの条件がそろっているときに、コマンド実行前に caffeinate コマンドが実行されていないという警告を出します。

  • 文字列 "caffeinate" と、直前のバックグラウンドプロセスの番号で grep したときに caffeinate コマンドのプロセスが 1 件ではない
  • caffeinate コマンドのバックグラウンドプロセス番号を保持する $_tn_caffeinate_pid の中身が数値ではない (空文字列などになっている)

また、precmd() では get_prompt_state() を呼び出していて、この関数で環境変数 PROMPT_STATE をセットしています。

PROMPT_STATE には caffeine_count() の実行結果が入っていて、caffeine_count()caffeinate コマンドのプロセスの数を返します。

あとはこれを環境変数 PROMPT の中に入れておけば、常にプロンプトに caffeinate コマンドのプロセスの数が表示されます。

ぼくは Starship を使っているので、環境変数 PROMPT に入れる代わりに Starship の env_var モジュールにセットしています。

[env_var]
variable = "PROMPT_STATE"
default = "???"
style = "bold yellow"

これで以下のように表示されます。

スクリーンショット 2021-01-13 18.44.02.png

“☕️ 0” と表示されていますね。caffeinate コマンドのプロセスがないことがわかります。ここの数字が 1 以上だった場合は、別のシェルセッションで何かしらのコマンドが実行されているので今はスリープしないということを表しています。

Bash や Linux では使えないのが弱点

Bash だと Bash-Preexec を使えば Zsh の preexec()precmd() 相当のことができるのですが、試してみたところ、残念ながら正しく機能しませんでした。

理由はわかりませんが、Bash-Preexec でバックグラウンドプロセスに回すような処理を書くと、インタラクティブシェルでパイプを使ったときにコマンドがストップしてしまいます。

[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh

preexec() {
  # バックグラウンドで実行する処理を書く
  caffeinate -d &
}
$ ps aux | grep caffeinate
# 処理がストップしてしまう

もしかしたらこれはバグかもしれないので、今後修正されるかもしれません。

Fish に関しては試していないのでわかりませんが、--on-event fish_preexec--on-event fish_postexec が、それぞれ Zsh の preexec()precmd() に似ているので代用できるかもしれません。

参考: シェルでコマンドの実行前後をフックする

また、この caffeinate コマンドは macOS (Darwin) 専用なので Linux では使えません。Linux にも caffeinate コマンド相当のコマンドがあれば使えると思います。