結論

  • tmux のキーバインド・オプション・@ 変数・環境変数はサーバのメモリにある。.tmux.conf はサーバ起動時に 1 回流すコマンド列で、行を消して source-file しても登録済みのものは消えない
  • ペイン・popup・run-shell・if-shell のプロセスは、操作中のシェルではなくサーバの環境を継承する。後から足した PATH は届かず、if-shell 'command -v ...' は偽になってブロックを警告なしに捨てる
  • popup とコマンド付きのペインのシェルは非ログイン・非対話で、zsh なら .zshenv しか読まない
  • 存在しない -c のパス、解決できない display-message -t、. や : を含むセッション名は、どれもその場ではエラーにならない (終了コード 0)。ずれは後で別の場所に出る
  • 確かめるときは -L <名前> -f /dev/null で隔離したサーバを使う。-L だけだと ~/.tmux.conf を読む

以下は tmux 3.7c / zsh 5.9.2 で実測した。コード例はすべて -L demo の使い捨てサーバに対して打つので、普段のサーバには触れない。

前提: tmux コマンドはサーバへの依頼でしかない

tmux コマンドはサーバへコマンドを送るだけで、設定はサーバ起動時に 1 回読まれ、ペインや popup はサーバが起動する

tmux はクライアント・サーバ型になっている。端末で打つ tmux ... はクライアントで、ソケット越しにサーバへコマンドを送って終わる。 キーバインド・オプション・セッション・環境変数はサーバのメモリにあり、サーバが終わるまで残る。

  • .tmux.conf を読むのはサーバの起動時だけ。新しいセッションを作っても読み直さない。source-file は書かれたコマンドをもう 1 度流すだけ
  • ペイン・popup・run-shell・if-shell のプロセスを起動するのはサーバ。操作中のシェルの子ではない

以下の罠は、どれもこの 2 点のどちらかから来る。

設定の行を消しても、キーバインドは消えない

行をコメントアウトして source-file しても、サーバに登録済みのキーバインドとオプションは残る

source-file はファイルに書かれたコマンドを実行する。行をコメントアウトすると、その行を実行しなくなるだけで、登録済みのキーバインドを消す命令にはならない。 オプションも同じで、set -g の行を消しても既定値には戻らない。

cd "$(mktemp -d)"
printf '%s\n' 'bind -n M-y display-message probe' 'set -g status-left "[probe]"' > t.conf
tmux -L demo -f t.conf new-session -d

sed -i 's/^/# /' t.conf                       # 2 行ともコメントアウト
tmux -L demo source-file t.conf
tmux -L demo list-keys -T root | grep M-y     # bind-key -T root M-y display-message probe
tmux -L demo show -gv status-left             # [probe]
tmux -L demo kill-server

消すなら unbind -n M-y や set -gu status-left を明示的に実行するか、kill-server でサーバを作り直す。 サーバオプション (set -s) も source-file で反映される点は set -g と同じで、別扱いは無い。

PITFALL: 消えたかはファイルではなく tmux list-keys と tmux show -g で確かめる。set -g mouse on のような別の設定で同じ操作ができていると、古いバインドが残っていても気付かない。

@ 変数はサーバの寿命だけ生きる

@ 変数は source-file を越えて残り kill-server で消えるが、run-shell -b の孫プロセスは kill-server の後も残る

set -g @name value のユーザオプションもサーバのメモリにある。source-file では消えず、kill-server で消える。 この寿命は「サーバ 1 回につき 1 度だけ実行したい処理」の条件と一致するので、そのまま実行済みフラグに使える。

if-shell -F '#{!=:#{@bootstrapped},1}' {
  set -g @bootstrapped 1
  run-shell -b 'do-something-once'
}

.tmux.conf は source-file のたびに頭から流れるので、素で書いた run-shell は再読込のたびに走る。 起動して source-file を 2 回打つと、素の run-shell は 3 回、フラグ付きは 1 回走った。kill-server して起動し直すと、フラグ付きもまた 1 回走る。

PITFALL: run-shell -b で起こしたプロセスのうち、シェルの子として動くものは kill-server で終わらない。一緒に終わるのは tmux が起動した sh -c までで、その子は孤児として残る (この環境では PPID が 1 になった)。

tmux -L demo -f /dev/null new-session -d
tmux -L demo run-shell -b 'sleep 321; true'   # sleep は sh -c の子として動く
tmux -L demo kill-server
ps -o pid,ppid,args -C sleep | grep 321       # 残っている

run-shell -b 'sleep 322' のように単純コマンド 1 つなら一緒に終わった。この環境の /bin/sh は bash で、sh -c がそのコマンド自身に置き換わるため。 daemon を起こす設定を試したあとは、kill-server とプロセスの後始末の両方が要る。

設定の読み込み中は、まだクライアントがいない

設定を読み込んでいる間はクライアントが attach しておらず、run-shell は必ず、run-shell -b は attach より先に動いたときに失敗する

サーバは .tmux.conf を読み終えてから、起動したクライアントを attach させる。 設定から switch-client のようにクライアントが要るコマンドを呼ぶと、相手がいない。

  • run-shell (-b なし) は設定の読み込みを止めて終わりを待つので、中の tmux switch-client は必ず no current client で失敗する
  • run-shell -b は attach と競争になる。後ろに何も無ければ attach が先に終わって成功した (5 回中 5 回)。後ろに時間のかかる行 (run-shell 'sleep 1') を置くと no current client で失敗した

対策は、attach をポーリングで待ってから動くスクリプトに分けること。

#!/bin/bash
# wait-switch.sh: クライアントの attach を待ってから移動する
for _ in $(seq 50); do
  [[ -n "$(tmux list-clients -F '#{client_name}' 2>/dev/null)" ]] && break
  sleep 0.1
done
tmux switch-client -t other
new-session -d -s other
run-shell -b '/path/to/wait-switch.sh'

PITFALL: 症状は「起動すると意図しないセッションにいる」になる。セッションが消えたのではなく、移動できていない。

-L が分けるのはソケットだけ

tmux -L <名前> は別のサーバを立てるが、変わるのはソケットのパスだけ。設定ファイルは同じ ~/.tmux.conf を読み、子プロセスの環境も変わらない。 設定の run-shell が daemon を起こしたりセッションを復元したりすれば、検証用のサーバからでも普段のデータディレクトリへ書き込む。

設定を読ませないなら -f /dev/null を足す。設定は読ませたうえで副作用を普段のサーバに限るなら、設定の側でソケット名を見る。 if-shell のシェルコマンドは実行前に format が展開されるので、#{b:socket_path} でサーバ自身のソケット名を判定できる。

if-shell '[ "#{b:socket_path}" = default ]' {
  run-shell -b 'start-my-daemon'
}

PATH はサーバを起動したときのまま

キーバインド・popup・run-shell・if-shell はサーバ起動時の環境を受け取り、端末で打った new-window だけは PATH を呼び出し元から受け取る

run-shell と if-shell のシェルはサーバの環境で動く。サーバの環境は、最初にサーバを起動したクライアントから 1 度だけ取り込まれる。 その後で対話シェルに足した PATH (~/go/bin など) は届かない。

cd "$(mktemp -d)"
mkdir bin && printf '#!/bin/sh\necho mytool\n' > bin/mytool && chmod +x bin/mytool
cat > t.conf <<'EOF'
if-shell 'command -v mytool >/dev/null 2>&1' {
  bind-key C-f display-message found
}
EOF
PATH=/usr/bin:/bin tmux -L demo -f t.conf new-session -d   # 短い PATH でサーバを起動
export PATH="$PWD/bin:$PATH"                                # 対話シェルにだけ足す
command -v mytool                                           # 見つかる
tmux -L demo run-shell 'command -v mytool || echo NOTFOUND' # NOTFOUND
tmux -L demo list-keys -T prefix | grep C-f                 # 何も出ない
tmux -L demo kill-server

if-shell が偽でもエラーは出ない。ブロックが丸ごと無かったことになり、「設定を書いたのに何も起きない」としか見えない。 条件の手前でサーバの PATH を補えば通る。今の値は tmux show-environment -g PATH で見る。

run-shell 'tmux set-environment -g PATH "$HOME/go/bin:$PATH"'
if-shell 'command -v mytool >/dev/null 2>&1' {
  bind-key C-f display-popup -E 'mytool picker'
}

キーバインドから起動した new-window と display-popup も、PATH はサーバのものだった。 例外は、端末で tmux new-window のように打った場合 (attach していないクライアントからの実行) で、新しいペインの PATH だけは打ったシェルの PATH になる。PATH 以外の変数は引き継がない。

tmux が起動するシェルの種類は、起動の仕方で変わる。zsh の各設定ファイルに印を書かせて、キーバインドから起動して測った (new-window は端末から打っても同じ結果だった)。

起動の仕方ログイン対話読んだファイル
new-window (コマンドなし)yesyes.zshenv .zprofile .zshrc .zlogin
new-window 'cmd'nono.zshenv
display-popup -E 'cmd'nono.zshenv
display-popup -E (コマンドなし)noyes.zshenv .zshrc
比較: zsh -lc 'cmd'yesno.zshenv .zprofile .zlogin

zsh は .zshenv を毎回、.zprofile と .zlogin をログイン時、.zshrc を対話時に読む。 PATH を .zshrc で組み立てていると、popup で起動したコマンドにはその分が無く、手で打てば動くコマンドが command not found になる。

zd=$(mktemp -d)
for f in .zshenv .zprofile .zshrc .zlogin; do echo "echo $f >> $zd/log" > "$zd/$f"; done
ZDOTDIR=$zd tmux -L demo -f /dev/null new-session -d 'sleep 60'
tmux -L demo set -g default-shell "$(command -v zsh)"
: > "$zd/log"; tmux -L demo new-window -d 'true'; sleep 0.5; cat "$zd/log"  # .zshenv
: > "$zd/log"; tmux -L demo new-window -d;        sleep 0.5; cat "$zd/log"  # 4 つとも
tmux -L demo kill-server

全部の起動で効かせたい PATH は .zshenv に置く。popup だけなら zsh -ic 'cmd' で包むと .zshrc を読む。

SSH_CONNECTION は attach したときにしか変わらない

SSH 側のクライアントが attach した値は、そのクライアントが抜けた後もセッション環境に残り、新しいペインへ渡る

update-environment に挙がった変数 (既定で SSH_CONNECTION SSH_AUTH_SOCK DISPLAY など) は、クライアントが attach した瞬間に、そのクライアントの値でセッション環境を上書きする。 セッション環境は全クライアントで共有なので、次の順に動くとずれる。

  1. ローカルの端末で attach したまま
  2. SSH から同じセッションに attach し、抜ける
  3. ローカルの端末でそのまま作業を続ける

3 の時点でもセッション環境には SSH 側の値が残り、以後に開いたペインはそれを受け取る。ローカルで attach し直すと消える。

tmux show-environment SSH_CONNECTION
# 1 の時点:  -SSH_CONNECTION   (先頭の - は「この変数を消す」の印)
# 3 の時点:  SSH_CONNECTION=192.0.2.1 50000 192.0.2.2 22

1 つのペインを複数のクライアントが同時に見られるので、「このペインは SSH 越しか」に答えは 1 つに決まらない。 今操作しているクライアントで判定するなら、セッション環境ではなくクライアントのプロセスの環境を読む。Linux なら /proc から読める。

pid=$(tmux display-message -p '#{client_pid}')
tr '\0' '\n' < "/proc/$pid/environ" | grep '^SSH_CONNECTION='

存在しない -c のパスは $HOME になる

保存したディレクトリが消えると復元で $HOME に開き、それが次に保存されて元のパスが失われる

new-session / new-window / split-window の -c に存在しないディレクトリを渡しても、終了コードは 0 で、ペインは $HOME で開く。呼び出し元のカレントディレクトリにもならない。

cd /tmp
tmux -L demo -f /dev/null new-session -d -s s -c /nonexistent/path; echo "rc=$?"     # rc=0
tmux -L demo list-panes -s -t '=s' -F '#{pane_current_path} #{pane_start_path}'     # /home/you /nonexistent/path
tmux -L demo kill-server

指定したパスは #{pane_start_path} に残る。

PITFALL: 作業ディレクトリを定期的に保存するセッション復元ツールと組み合わせると、元に戻らない形で壊れる。 保存したディレクトリが消える => 復元で $HOME に開く => 次の保存で $HOME が記録される、と進むと元のパスは失われ、以後は「パスが存在しない」では検出できない。復元の前に判定する。

display-message -p -t は解決できない対象を空で返す

display-message の -t は、対象が見つからなくてもエラーにならない。format は空文字になり、終了コードは 0 になる。 セッション名に完全一致の = を付けた -t '=probe' は pane の指定として解決されず、この形になる。末尾に : を付ければ解決する。

tmux -L demo -f /dev/null new-session -d -s probe
tmux -L demo display-message -p -t '=probe'  '#{session_name}|#{session_windows}'   # |
tmux -L demo display-message -p -t '=probe:' '#{session_name}|#{session_windows}'   # probe|1
tmux -L demo display-message -p -t 'nosuch'  '#{session_name}'; echo "rc=$?"       # (空行) rc=0
tmux -L demo kill-server

「セッションを kill してよいか」のようなガードをここで組むと、全部が空文字になって判定が崩れる。セッションの属性は、列挙系のコマンドに -F を渡して読む方が確実。

tmux list-sessions -f '#{==:#{session_name},probe}' -F '#{session_attached}'
tmux list-panes -s -t '=probe' -F '#{pane_current_command}'   # 1 行 = 1 ペイン

PITFALL: tmux コマンドを偽物に差し替えたユニットテストでは、この挙動は出ない。実物の tmux に当てて確かめる。

display-popup -E は標準出力を捨てる

popup のコマンドの標準出力は popup の画面で消え、呼び出し元へ戻すには send-keys で送る

popup のコマンドはサーバが起動する別の端末で動く。標準出力は popup の画面に出て、閉じると消える。呼び出し元のシェルは $(...) で受け取れない。

# tmux の中のシェルで打つ
out=$(tmux display-popup -E 'echo hello'); echo "[$out] rc=$?"   # [] rc=0

「パスを出力して、呼び出し元が cd する」スクリプトを popup に載せ替えると、ここで止まる。 popup から呼び出し元のペインへ作用させるには send-keys を使う。popup の中では $TMUX_PANE が空で、display-message -p '#{pane_id}' が呼び出し元のペインを返す。

pane=$(tmux display-message -p '#{pane_id}')
tmux send-keys -t "$pane" "cd $(printf '%q' "$dst")" Enter

PITFALL: パスに空白や引用符が入ると、send-keys で送った文字列がそのままシェルに解釈される。printf '%q' でクォートしてから送る。

-F の \t はタブにならない

format はバックスラッシュのエスケープを解釈しない。-F '#{a}\t#{b}' は \t の 2 文字をそのまま出す。

tmux -L demo -f /dev/null new-session -d -s s
tmux -L demo list-sessions -F '#{session_name}\t#{session_windows}'   # s\t1

区切りに頼らず、空白を含みうる値を最後に回して、read の最後の変数に残り全体を吸わせる。

tmux -L demo rename-window -t '=s:0' 'my window'
tmux -L demo list-windows -t '=s' -F '#{window_index} #{window_panes} #{window_name}' |
  while read -r idx panes name; do echo "$idx|$panes|$name"; done   # 0|1|my window
tmux -L demo kill-server

. と : を含むセッション名は、作れるが名前で指せない

-t の文法は : と . で区切るので、a.b は b を pane として探して失敗し、末尾に : を付けると a.b 全体がセッション名として読まれる

new-session -s 'a.b' は成功し、名前もそのまま残る。ところが -t の文法では : が session と window を、. が window と pane を区切るので、後から名前で指せない。

tmux -L demo -f /dev/null new-session -d -s keep
tmux -L demo new-session -d -s 'a.b'; echo "rc=$?"   # rc=0
tmux -L demo has-session -t 'a.b'                    # can't find pane: b
tmux -L demo has-session -t 'a.b:'; echo "rc=$?"     # rc=0
tmux -L demo kill-server
名前作成-t 名前-t 名前:
a.b成功can’t find pane: b指せる
.dot成功can’t find pane: dot指せる
c:d成功can’t find window: dcan’t find window: d:
release/1.0成功指せる指せる

c:d は c:d =c:d c:d: =c:d: のどれでも指せなかった。list-sessions -F '#{session_id}' で取れる $1 のような ID なら指せる。 ディレクトリ名やブランチ名からセッション名を作るなら、作る前に置き換える。

name=${name//[^A-Za-z0-9_-]/-}   # 英数字・_・- 以外を - に
name=${name#-}                   # 先頭の - を落とす

まとめ

罠症状対策
設定の行を消して source-fileバインドとオプションが残るunbind / set -u を打つか kill-server
@ 変数source-file で消えない実行済みフラグに使う
run-shell -b の孫プロセスkill-server 後も残るプロセスも後始末する
設定から switch-clientno current clientattach をポーリングで待つ
-L <名前>~/.tmux.conf を読む-f /dev/null か #{b:socket_path} で分岐
サーバの PATHif-shell がエラーなしに偽set-environment -g PATH で補う
popup / コマンド付きペイン.zshrc を読まないPATH は .zshenv に置く
update-environmentSSH の値がローカルに残る#{client_pid} の環境を読む
-c <存在しないパス>$HOME で開き rc=0復元前に存在を確かめる
display-message -p -t '=名前'空文字で rc=0=名前: か list-sessions -F
display-popup -E標準出力が消えるsend-keys で呼び出し元へ送る
-F の \t\t の 2 文字が出る最後の項目に空白を吸わせる
. : 入りのセッション名作れるが指せない作る前に置き換える

参考