何か下準備をしたり環境変数などに変更を加えてから指定したコマンドを起動するプログラムはたくさんある。bundle exec
や npx
, uv run
や、拙作でも envchain などがそのようなプログラムの例だと思う。このようなプログラムは世間では executor と呼ばれているような気がするので、ここでもそう呼ぶことにする。
executor を最近あたらしく書いていて、ただそこでは指定されたコマンドを起動するだけではなく、そのコマンドのためのサーバーを裏で実行し続ける必要があり、ちょっとした工夫が必要だったのでメモ。
(なお、executor に指定されたコマンド = ユーザーや executor の親プロセスが最終的に起動を期待しているプログラムについては以後 command と表記)
tl;dr
executor が command の子プロセスになるようにする。executor は fork して必要なサーバー等を子プロセスで動作させ、executor の親プロセスは command を exec(3) する。
答えが分かってしまえば簡単。何故そうする必要があるのか、またどう実装すると良いのか、については下記に続きます。
exec(3) を利用する executor
UNIX では executor は command の実行について exec(3) 族を利用して実装されることが多いはず。これは同一 PID でそのまま command に実行を移せるため、呼び出し元から executor の存在は透過的となり、都合が良いから。
exec の利点は PID が変わらない他に、シグナルや終了ステータスの扱いがある。親プロセスはユーザーが操作するシェルではなく、他のプログラムである場合も executor は考慮しておかなければいけない。たとえば、親が起動した子プロセスへシグナルを送信したい時は起動した際に得られた PID を利用して送信するのが通常で、また子プロセスのコマンドはユーザーが指定できる場合も多い。
exec を使えば親プロセスからみた子プロセスの PID が executor から最終的には command に変化するため、親から executor の存在は透過と言える。親プロセスに伝わる終了ステータスも exec 以降は command のものとなるため、この観点でも透過になる。
command の裏で executor の何かを動作させ続けたい
しかし exec すると元のプログラムの動作は継続しない。executor が下準備をして動作を command に引き渡すだけなら問題ないところ、executor が command のために何かを動作させ続けたい場合に不都合がある。たとえば TCP や UNIX ドメインソケット上でサーバーを起動しておきたい、 command の様子を監視しておきたい…といったユースケースが考えられる。
このような場合では executor はそのまま exec する訳にはいかないため、posix_spawn (fork + exec) など、executor の子プロセスとして command を実行する実装が素朴な選択肢になる。ただ、spawn での実装では先に書いた exec による親プロセスからの透過性を得る事が難しくなる。
たとえば、シグナルに関しては tty から届きうる SIGINT, SIGQUIT, SIGTSTP など一部を無視する必要がある 1 。その上で、command が親プロセスなどから期待するシグナルは executor は汎用的には分からないため 2、ユーザーに executor から転送してほしいシグナルを指定してもらう必要が出てくる。これではユーザーに executor の動作機序を理解して利用してもらう必要があり、体験が悪い。
終了ステータスに関しても、(UNIXでは) 親プロセスは子プロセスの終了に関して、通常の終了コードの他にシグナルによって kill された場合はそのシグナルの情報を取得できる。終了コードについては executor から伝搬できるが、シグナルの情報を伝搬するのは難しい。
解: executor が command の子プロセスになれば良い
これに対する解は意外にもシンプルで、fork + exec にして、 exec は fork した時の親でやれば良い。つまり、executor が動作させておきたい物は command の子プロセスにしてしまえばいい。
-
素朴に posix_spawn (fork+exec) した場合:
- 起動元
- executor
- command
- executor
- 起動元
-
fork して親プロセスで exec した場合:
- 起動元
- command
- executor (sidecar)
- command
- 起動元
図示するまでもないだろうけど、上記に図示した通り、この実装パターンだと executor の起動元に対して executor は引き続き透過になる。ここでは、この command の子になった executor は sidecar と称することにする。
sidecar はいつ終了すればいいか
sidecar がその動作を終えて終了する際は、sidecar の中身にも依存するだろうけれど、大概は command が終了してからになるはず。しかし、sidecar 視点で command を待ち合わせるには子プロセスではなく、親プロセスの終了を待つことになる。
これは UNIX では getppid(2) を監視すれば実現できる。executor が fork する前に executor の PID (= command の PID)を getpid(2) で取得して sidecar に持たせておき、PPID - getppid(2) をポーリングして変動すれば command もしくは executor が終了した事が検知できる。
PPID のポーリングに加え 3、一部のプラットフォームでは OS から通知を貰える。Linux では prctl(2) で PR_SET_PDEATHSIG、FreeBSD/DragonflyBSD では procctl(2) で PROC_PDEATHSIG_CTL を利用すれば親プロセスの終了に合わせて指定したシグナルを貰えるようになる。なお、残念ながら macOS にそんな仕組みはないので、諦めてポーリングしよう。
sidecar の起動を待って command を起動したい
また、sidecar の処理によっては executor が command を起動する前に sidecar の準備が整った事を待ち合わせたかったり、sidecar から何か情報を受け取ってから起動したいパターンもある。
これは特に難しいことはなく、executor が fork 前に pipe を作って、sidecar から通知すれば良い。pipe2(2) に O_CLOEXEC を指定しておくと exec 前に手で close する必要がなくて便利。
実装例
拙作の mairu では executor として利用された際に HTTP サーバーを sidecar として動作させる必要があり、上記の実装に辿りついた。実装例は下記の通り。
- spawn していた頃の signal の無視と転送: https://github.com/sorah/mairu/pull/10
- sidecar の実装への切り替え: https://github.com/sorah/mairu/pull/11
あわせて読みたい | Acknowledgements
- ssh-agent には executor としての動作モードがあり、command を指定すると SSH_AUTH_SOCK を設定して ssh-agent が command の子プロセスとして動作してくれる、というのを教えてもらいました (Thanks @k_hanazuki)
- あと ssh-agent にはあらゆるコツがある: ssh-agent のしくみ - eagletmt's blog
-
TTY line discipiline から届くのは Linux では SIGINT, SIGQUIT, SIGTSTP [n_tty.c] で、^C 等の入力でシグナルに変換される。その上で、プロセスグループに対して送信されるため、むしろ executor からは無視してあげないと、command より先に executor が終了する、executor が終了してcommand が動作しているのに先にシェルプロンプトに戻ってしまう、といった事が起きる ↩
-
プロセスグループへ送信された場合は下手にデフォルトで転送しておくと二重に command へ送信されてしまう場合もあるため、デフォルトで転送をする挙動はそれはそれで問題になる場合がある ↩
-
通知を設定する前に親プロセスが終了する場合は race condition となるため、引き続きポーリングは必要 ↩