お魚大好き

大規模ソフトウェア実験でfish shellを手探るブログ。

fish shellにwaitコマンドを追加した

はじめに

この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。
この記事は実験六日目から十日目までの内容にあたる。五日目までに行った内容については、

fish2017.hatenablog.com

を参照のこと。ソースコードからのビルドの手順・デバッグの手順なども書いているので、つまづいた人はそちらを見てください。

やったこと

bashなどには wait というバックグラウンドプロセスの終了を待つ組み込みコマンドが存在するが、fishには存在しない。どうやらissueも立ってはいる様子(ただ、1年くらい放置されている……?)。 github.com というわけで、bashの実装を参考にfishにwaitコマンドを実装した。

実装した機能の詳細

sleep 1 &
sleep 2 &
wait

のように、引数なしで実行すると、実行中のすべてのバックグラウンドジョブの終了を待つ。この場合だと、2秒待つことになる。
あるいは、

sleep 1 &
sleep 2 &
wait %1

のようにジョブIDやPIDを指定すると、指定したジョブのみの終了を待つ。
この場合、sleep 1のジョブIDは他に停止中のジョブや実行中のジョブがなければ通常1となるはずである。よって、上と異なり、1秒だけ待つことになる。
また、-nというオプションを実装している。これは、指定したジョブ(指定がなければ全ての実行中のジョブ)のうち、1つが終了するのを待つというコマンドで、例えば、

sleep 1 &
sleep 50 &
sleep 2 &
wait -n %2 %3

のようにすると、sleep 50sleep 2のいずれかの終了を待つ。よって、この場合だと2秒待つことになる。

手探った流れ

手探る

bashの実装を参考にするため、fishのソースを読みながら、並行してbashのソースも読み進めた。

fishでのビルトインコマンドの実装

プロジェクト全体を適当なビルトインコマンドの名前でgrepしながら調べた結果、ビルトインコマンドの一覧はbuiltin.cpp内に以下のように記述されていた。

static const builtin_data_t builtin_datas[] = {
    {L"[", &builtin_test, N_(L"Test a condition")},
    {L"and", &builtin_generic, N_(L"Execute command if previous command suceeded")},
    {L"argparse", &builtin_argparse, N_(L"Parse options in fish script")},
    {L"begin", &builtin_generic, N_(L"Create a block of code")},
    {L"bg", &builtin_bg, N_(L"Send job to background")},
...

&builtin_testの部分には、そのコマンドが行う処理を記述するコールバック関数が登録されている。
fishのソースではある程度大きなコマンドはbuiltin_test.cpp、builtin_argparse.cppのようにコマンド毎にファイルを分けているため、これに倣ってbuiltin_wait.cppを用意してやり、そこにwaitの処理を記述する関数を書けば良さそう、というところまでわかった。

bashを読む

bashデバッグしつつ、ソースコードを読む。 分かったことを以下にまとめる。

  • waitコマンドの実装はbuiltins/wait.defにある。中身はc言語での実装なのになんで拡張子が.defなの・・・
  • 実際に待つ処理を行っているのはjobs.cの中の
    • wait_for_background_pids関数(バックグラウンドのプロセスを全て待つ)
    • wait_for_single_pid関数(指定したpidの終了を待つ)
    • その中で色々あって結局はシステムコールのwaitpid関数が呼ばれている
  • wait_for_background_pids関数からwait_for_single_pid関数が呼ばれているが、その際にはpidのリストの中から順番にpidを取り出し、待つ、という処理を行っているため、ジョブが終了した順番に通知されない場合があるという問題点を発見

waitの主要な処理としてはこんな感じである。他にも分かったこと(理解できなかったこと)を以下にまとめるので、興味のある方は読んで欲しい。

  • とにかくマクロが多い
  • シグナル関連の処理が多く、そのあたりあまり詳しくなかった自分にはあまり理解が及ばなかった。
  • ジョブの操作をしている際にSIGCHLD(子プロセスが死んだ際に親プロセスに送信されるシグナル)をsigprocmaskを呼んで部分的に無視するような記述が多用されていた。具体的にSIGCHLDをブロックしないとどういう事態になるのか分からなかったが、ジョブの操作をしている最中にジョブのリストの内容が書き換わってしまっては良くない、ということなのだろう。そもそもそんな悪いタイミングでシグナルが送られてしまうことはめったにないだろうが、bashの長い歴史の中で色々あったのだろうか・・・
  • シグナルハンドラの実装にはlongjmpが使用されているようだった。歴史を感じた。

fishに比べるとかなり古いコードなのでなんとなく新鮮な感じがして面白かったが、fishに比べてかなり難解であった。

実装

オプションと引数の処理は他のビルトインコマンドの処理に倣い(wgetopt.cppにオプションの処理を行ってくれる関数類が用意されている)、ジョブの管理は

  • bg(指定したプロセスをバックグラウンドに送る)
  • jobs(バックグラウンドジョブの一覧を表示)

などの今回やりたいことと近い処理を行うコマンドを参考にした。

とりあえずまず最初に、プロセスの終了を待つ部分はbashでの実装を参考に、

  1. ループ内でシステムコールwaitpid(pid_t pid, int *status, int options)の引数pidに-1を与えて任意のプロセスの終了を待機
  2. 終了したプロセスのIDを返り値として取得
  3. その値を見て待つべきジョブが全て完了したかを判別する

といった風に実装した。
また、プロセスに関する処理は基本的にproc.cpp内で行うようになっているので、waitpid()などの直接プロセスを扱う処理はproc.cpp内にproc_wait_any()という関数で切り出し、それをbuiltin_wait.cppから呼ぶようにした。

さて、ここから多少細かい話をするので興味のない人は飛ばしてください。
実行中のジョブに対する操作はjob_iterator_tを通じてparser_tの静的メンバprincipal_parser()job_listを操作することで行われる。
通常、job_listからのジョブの削除は、proc.cppのjob_reap関数を通して行われる。job_reap関数が行っているのは、以下のような処理である。

  1. SIGCHLDを受け取った数、すなわち終了したが未処理の子プロセスの数の分だけwait_pid関数を呼ぶ(既に終了したプロセスなので、wait_pidはすぐ処理を返す)
  2. その終了ステータスなどから、各プロセスに対して完了フラグを立てる
  3. 各ジョブに対して、各ジョブの持つプロセスが全て完了フラグが立っていたらそのジョブを削除する

waitコマンド内の処理において、最初の実装の通り単にシステムコールwaitpid()を呼ぶだけだと、プロセスの完了フラグが更新されず、完了したジョブがリストから削除されなくなってしまう。このため、waitpid()を行った後、取得した終了ステータスをもとにプロセスのフラグを更新し、適切にジョブを削除する必要がある。

結局どうしたか

上記の通りproc.cppのjob_reap関数が近いことをやっていたので、このコードを流用させてもらうことにした。 proc.cppに新たに追加したproc_wait_any関数の中では、

  1. waitpid(-1,...)を呼ぶ
  2. プロセスに対して完了フラグなどを立てる関数handle_child_statusを呼ぶ(既存の関数)
  3. job_reap関数の中でジョブの削除を行っている部分を関数に切り出し、それを呼ぶ(ここではprocess_clean_after_marking関数とした)

これで、proc_wait_any関数を呼べばジョブの削除を終えるところまでをやってもらうことができ、あとはいい感じにオプションや引数によって処理を変えるだけである。また、既存のコードを流用したことでジョブが終了した時点で通知が出るようになった。

Ctrl-Cで終了させたい

waitの機能はいい感じで実装できたが、Ctrl-Cで終了されない問題が発覚。 どうやら、builtinのコマンドはプロセスをforkせず、メインスレッド内でコマンドの処理が行われるためにfish本体のシグナルハンドラにSIGINTが処理されてしまうようであった。 そこでbashソースコードを読むと、bashでもwaitコマンドはメインスレッド内で行われているようである。bashではwaitに対して特別なシグナルハンドルの処理が書かれており、

  1. waitが実行中かどうかのフラグを立てる
  2. waitの実行中にSIGINTを受信した場合、longjmpを用いてwait.def(waitの実装のあるファイル)内にジャンプし、wait_builtin関数から強制的にreturnさせている

といった実装であった。 fishではlongjmpなんて使われていないし、どう実装しようかと考えていたところ、waitpid関数はSIGINTなどの割り込みが入った場合、errnoにEINTRが格納され-1を返すことが分かった。 となれば、waitpidが-1を返した場合にerrnoをチェックし、EINTRと合致した場合にreturnさせれば良いのである。 これはwaitpidが呼ばれてブロックされている状態でCtrl-Cを受信した場合にのみ有効で、もし他の処理をしている時にSIGINTを受信すると終了しない、という問題が考えられるが、そのようなことはめったに起こらないはず...である。

テストを書く

テストについてはじめはよく分からなかったのだが、どうやらコマンドごとに分かれていて

  • command.in
  • command.out
  • command.err

の3つのファイルが必要なようである。それぞれ、.inでの標準入力に対し、期待される標準出力.out標準エラー出力.errを予め記載しておき、異なるものが得られた場合はテスト失敗、といった流れで進められる。

対話的にテストを記述したい場合は、コマンドの対話を自動化するexpectコマンドの書式に倣って

  • command.expect
  • command.expect.out
  • command.expect.err

の3つのファイルを作成すれば良い。 fish独自のexpect_promptなどの関数も用意されており、実装はtests/interactive.expect.rcに記載されている。 大体の場合、他のコマンドのテストの書き方に合わせてテストを書けばなんとかなる。

今回は、waitが順番通り待てているかどうか、バッググラウンドジョブがないときに無限に待つことがないか、などを重点的にテストした。

プルリクを送ってみた

さて、一通り実装が済んだので、例によってない英語力を駆使してPull Requestを送ってみました。

github.com

10日くらい前に送ったのですが、fishのオーナーの方から「レビューするよ!」のコメントがあったまま動きがありません。 ブログを書く頃には何か動きがあったら良いな〜と思っていたのですが、まあみなさん忙しいでしょうし仕方ないですね。

また、何か動きがあったらブログにまとめようかと思っています。

感想

  • シェルスクリプトを普段書かないので、waitがどういう場面で使われるか想像がつかず、どういうバグを想定していいかわからなくてつらかった。
  • 作業を始める前にGitHubのissueを一通り読んだが、英語がわからない + POSIXがわからないので何を言っているかわからずつらかった。
  • とはいえ、真面目にシェルの内部の処理を覗いたのでかなり勉強になった。
  • 想像していた以上に低いレイヤ(シグナル周りなど)を触ることになり、勉強にはなったがかなり大変だった。
  • 最終発表日にソースをいじっていたらセグフォが出はじめて笑った。
  • やばそうなバグの種を仕込んでいたことにも気付いて笑った。
  • それを直していたら一応完成はしていた-nオプションが発表に間に合わなくなって笑った。
  • OSSへのコントリビュートの敷居が下がったので、他にもチャレンジしてみたいという気持ちが芽生えた。
  • 良い実験だった。

次回、「マージ」(2017/11/17 追記)

fish2017.hatenablog.com