【祝】fish shellにwaitコマンドがマージされた
はじめに
この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。 waitコマンドを実装した詳細については、
を参照のこと。
マージされました
前回送ったプルリクがmasterブランチにマージされました!!!!!!やったね
素直に嬉しい。
どうやら次期バージョンfish-3.0からwaitコマンドが有効になるらしい。 masterブランチには既にマージ済なので、本家fish-shellのgithubからcloneしてビルドすればwaitコマンドが使えるようになっている...。感動...。
fish-3.0のリリースが待ち遠しい。
加えられた修正
マージされるまでにレビューを受けて修正、といった作業が必要になるのかと思っていたが、そのままマージされて少し驚いた。 しかしよく見てみるとマージ後に若干の修正が加えられているようで、
上のリンクのようにCMakeやXcodeのためのビルド設定の追加や、関数の引数にvectorを渡すときに参照渡しになっていなかったのを直されたりしている。 また、
このようにモダンな文法に修正されたりしているようだ。 モダンな文法というのは
job_t *j;
if (j = ...
を
if (job_t *j = ...
と書く、といった程度のことで、大きな修正は加えられていないようだった。
if文の中でも変数定義できたのか...。
感想など
正直、学生実験としてfish shellを手探ることになった時、マージまでされるような機能を実装できるとは思っていなかった。 大規模ソフトウェアをこうして手探ってみた感想として、必要な知識を揃え、そのソフトウェア特有の慣習を理解すれば、あとは授業で習ったような普通のプログラムが動いているだけなのだなと感じた。
とにかく、マージされて嬉しかったので今は他のソフトウェアにも手を出してみたい気持ちである。
実験開始後すぐは自分の能力で実験内容についていけるのか心配だったが、手厚いサポートと優秀なペアのおかげでメチャクチャなんとかなった。なんとかなりすぎてマージされた。やったー!!! うれしー!!!
テーマ決めから実際の作業まで、いろいろと助言をくださった田浦先生やTAさんには感謝してもしきれません。楽しい実験でした。
fish shellにwaitコマンドを追加した
はじめに
この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。
この記事は実験六日目から十日目までの内容にあたる。五日目までに行った内容については、
を参照のこと。ソースコードからのビルドの手順・デバッグの手順なども書いているので、つまづいた人はそちらを見てください。
やったこと
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 50
とsleep 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での実装を参考に、
- ループ内でシステムコール
waitpid(pid_t pid, int *status, int options)
の引数pidに-1を与えて任意のプロセスの終了を待機 - 終了したプロセスのIDを返り値として取得
- その値を見て待つべきジョブが全て完了したかを判別する
といった風に実装した。
また、プロセスに関する処理は基本的に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関数が行っているのは、以下のような処理である。
- SIGCHLDを受け取った数、すなわち終了したが未処理の子プロセスの数の分だけwait_pid関数を呼ぶ(既に終了したプロセスなので、wait_pidはすぐ処理を返す)
- その終了ステータスなどから、各プロセスに対して完了フラグを立てる
- 各ジョブに対して、各ジョブの持つプロセスが全て完了フラグが立っていたらそのジョブを削除する
waitコマンド内の処理において、最初の実装の通り単にシステムコールのwaitpid()
を呼ぶだけだと、プロセスの完了フラグが更新されず、完了したジョブがリストから削除されなくなってしまう。このため、waitpid()
を行った後、取得した終了ステータスをもとにプロセスのフラグを更新し、適切にジョブを削除する必要がある。
結局どうしたか
上記の通りproc.cppのjob_reap関数が近いことをやっていたので、このコードを流用させてもらうことにした。 proc.cppに新たに追加したproc_wait_any関数の中では、
waitpid(-1,...)
を呼ぶ- プロセスに対して完了フラグなどを立てる関数handle_child_statusを呼ぶ(既存の関数)
- job_reap関数の中でジョブの削除を行っている部分を関数に切り出し、それを呼ぶ(ここではprocess_clean_after_marking関数とした)
これで、proc_wait_any関数を呼べばジョブの削除を終えるところまでをやってもらうことができ、あとはいい感じにオプションや引数によって処理を変えるだけである。また、既存のコードを流用したことでジョブが終了した時点で通知が出るようになった。
Ctrl-Cで終了させたい
waitの機能はいい感じで実装できたが、Ctrl-C
で終了されない問題が発覚。
どうやら、builtinのコマンドはプロセスをforkせず、メインスレッド内でコマンドの処理が行われるためにfish本体のシグナルハンドラにSIGINTが処理されてしまうようであった。
そこでbashのソースコードを読むと、bashでもwaitコマンドはメインスレッド内で行われているようである。bashではwaitに対して特別なシグナルハンドルの処理が書かれており、
- waitが実行中かどうかのフラグを立てる
- 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を送ってみました。
10日くらい前に送ったのですが、fishのオーナーの方から「レビューするよ!」のコメントがあったまま動きがありません。 ブログを書く頃には何か動きがあったら良いな〜と思っていたのですが、まあみなさん忙しいでしょうし仕方ないですね。
また、何か動きがあったらブログにまとめようかと思っています。
感想
- シェルスクリプトを普段書かないので、waitがどういう場面で使われるか想像がつかず、どういうバグを想定していいかわからなくてつらかった。
- 作業を始める前にGitHubのissueを一通り読んだが、英語がわからない + POSIXがわからないので何を言っているかわからずつらかった。
- とはいえ、真面目にシェルの内部の処理を覗いたのでかなり勉強になった。
- 想像していた以上に低いレイヤ(シグナル周りなど)を触ることになり、勉強にはなったがかなり大変だった。
- 最終発表日にソースをいじっていたらセグフォが出はじめて笑った。
- やばそうなバグの種を仕込んでいたことにも気付いて笑った。
- それを直していたら一応完成はしていた-nオプションが発表に間に合わなくなって笑った。
- OSSへのコントリビュートの敷居が下がったので、他にもチャレンジしてみたいという気持ちが芽生えた。
- 良い実験だった。
次回、「マージ」(2017/11/17 追記)
fish shellに楽に改行できるキーバインドを追加した
概要
この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。
fish shellについて
fish(Friendly interactive shell)はUNIXにおけるシェルの一つ。自分で設定ファイルを書いていく必要のあるbashやzshなどと違い、初期設定のままでも多くの便利な機能が使える点が特徴。
やったこと
実験2日目にテーマを決め、まず最初は軽めの機能を追加してみようということで、5日目までは『改行( \\n)を挿入するキーバインドを追加する』という目標に取り組んだ。
改行機能の詳細
ご存じの方も多いとは思うが、多くのシェルで"\"(バックスラッシュ)を打ってからエンターキーを押すと、新しい行にコマンドの続きを打てるようになる。 例えば、
$ echo \ hoge
のように打つことができ、結果はもちろんhoge
が出力される。
ただ、もっと快適に改行がしたいのである。改行をするためにスペース・バックスラッシュ・エンターの3つものキーを押したくないのである。
そこで、多くのGUIアプリケーションに倣ってShift+Enter
で改行、Enter
で実行、という形態をとればより直感的に、楽に改行ができるのではないかと考えた。
つまり、実装したい機能としてはこうである。例えば
$ ps aux
と打ったところでShift+Enter
を押すと
$ ps aux \
というようにスペース・バックスラッシュ・改行が自動で補完される。
パイプで繋がれた長いコマンドを打ちたい時にも、この機能を使えば簡単に改行ができ、
$ ps aux \ | grep bash \ | awk '{print $2}' \ | xargs kill -9
このように見やすい形でコマンドを打つことができる、というわけである。
手探った流れ
ビルドまで
まずはソースコードをfishのGitHubから拾ってきて自分のPCでビルドするところから。README.mdを見ると、
autoreconf --no-recursive #if building from Git ./configure make sudo make install
このように書かれているのでこの通りやれば良いのだが、いくつか注意点がある。
今回はgdbでプログラムの流れを追いたいので、コンパイル時のオプションに-O0 -g
(-O0
: 最適化なし、-g
: デバッグシンボルの追加)を指定しておきたい。
その場合は./configure
を行う前に環境変数CXXFLAGS="-O0 -g"
を指定しておく。ここで注意が必要なのが、fishはC++で書かれているのでCFLAGS
ではなくCXXFLAGS
を指定する、ということだ。これを間違えると自分たちのようにデバッグの時に正しく動作しなくなって困ることになる。
ちなみにCPPFLAGS
という紛らわしい物のもあるので騙されないようにしたい。
また、デバッグだけしたいときはインストール先を変更したほうが良く、--prefix
オプションでインストール先のディレクトリを変更できる。権限のあるホームディレクトリ以下を指定しておけばインストール時にsudo
を求められることもなかろう。
以上をまとめると、./configure
を実行するときは以下のようにすれば良い。
$ CXXFLAGS="-O0 -g" ./configure --prefix=/home/(ユーザ名)/fish-install
ちなみにbashではこの書き方で問題ないが、fishではコマンドの前に環境変数を指定する書き方ができないので、
$ set -x CXXFLAGS "-O0 -g" $ ./configure --prefix=/home/(ユーザ名)/fish-install
のようにする必要がありそう。
これでビルドが完了し、fishを手探る準備が整った。
手探る
デバッグの前準備
gdbでプログラムの流れを追い、関連する処理を行っている関数を探す。ここでは、入力に応じてどんな処理が呼び出されるかをみたいので、別の端末から入力を行いながらデバッグする。
gdbを動かすターミナルと別にもう一つターミナルを立ち上げ、 reptyr を使って端末を確保する。
$ reptyr -l Opened a new pty: /dev/pts/(適当な数が入る)
次に、gdbの側で
(gdb) set inferior-tty /dev/pts/(適当な数が入る) (gdb) run
のようにすると、さっき立ち上げた別の端末でfishが動き始め、入出力はそちら側のターミナルでできるようになる。
あるいは、gdbのattach
機能を使って既に起動してあるfishのプロセスに対してデバッグを行っても良いだろう。
デバッグの流れ
デバッグの流れとしては、
- とりあえずmain関数にブレークポイントをおく
n
で処理を一行ずつ進めていき、怪しげな関数があったらs
で関数の中に入る
といった感じで進めていくことが多いだろう。 今回の場合、キーバインドを押した時の処理がどのように行われているか知りたかったので
- とりあえずブレークポイントは置かず、fishが入力待ちになるまで待つ
- gdb側で
Ctrl-C
を押し、一時停止したらn
で次に進む (この作業を行わないとキーを入力しても処理が流れてしまい、デバッグできない) - 再び入力待ちの状態になる
- 入出力を行う方のターミナルで適当なキーを入力してみる (今回の目的は 『あるキーを押すとコマンドラインに特定の文字列が出力される』 というものなので、 Ctrl-V (ペースト) などを入力して処理を追いかけてみた)
- 何らかのキーが入力された際に呼ばれる部分の処理を追う
- さらに深く処理を追いかけ、怪しげな関数を探す。適宜ブレークポイントをおく
といった流れでデバッグを進めた。
また、並行して input とか key とか bind とかの いかにも な単語でgrepしてみると関係ありそうな関数が見つかり、精神衛生上優しい(ステップ実行は疲弊する作業なので)。
わかったこと・結果
Shift+Enterが取得できない・・・?
input.cppのinput_readch関数の中に
wchar_t c = input_common_readch(0);
という記述があり、ここで押されたキーを取得している。p c
として変数c
の中身を出力してみると、Enter
を押した時もShift+Enter
を押した時もいずれも'\r'
が入力されたことになっていた。
他のキーについても何が取得されていくかを追っていくうちに、どうやらCtrl
やShift
と同時にあるキーが押された、という情報は制御文字あるいはエスケープシーケンスとして、単一または複数の入力が渡されているようなのである。
しかし、残念なことにShift+Enter
には特殊なエスケープシーケンスが割り当てられている様子もなく、調べてみると多くのターミナルの仕様でShift+Enter
とEnter
は区別して送信されない、ということが判明した。
結論としては、fishをいくらいじってもShift+Enter
の入力は受け取れない、ということであった。悲しい。
悲しいが、仕方ないので代わりにCtrl+O
に改行機能を割り当てることにした。
機能自体の実装
キーボードからの入力を解釈し、実際の処理を行っているのはreader.cppのreader_readline()関数のこの辺
case R_YANK: { yank_str = kill_yank(); insert_string(data->active_edit_line(), yank_str); yank_len = wcslen(yank_str); break; } case R_YANK_POP: { if (yank_len) { for (size_t i = 0; i < yank_len; i++) remove_backward(); yank_str = kill_yank_rotate(); insert_string(data->active_edit_line(), yank_str); yank_len = wcslen(yank_str); } break; }
yankもユーザの入力中の行に対する操作だし、case R_YANK:
をこんな感じ
case R_YANK: { const wchar_t *str_to_insert = L" \\\n"; insert_string(data->active_edit_line(), str_to_insert); break; }
で書きかえれば、yankを所望の動作で置き換えられるのでは?
→できました
というわけで、他のinput functionの形式に倣ってちゃんと実装する。
input.cppの中で定義されるinput functionを示す定数
static const wchar_t *const name_arr[] = {L"beginning-of-line", L"end-of-line", L"forward-char", ... static const wchar_t code_arr[] = {R_BEGINNING_OF_LINE, R_END_OF_LINE, R_FORWARD_CHAR, ...
に L"new-line-with-backslash"
と R_NEW_LINE_WITH_BACKSLASH
を加える。
ちなみに、name_arr[i]
と合致するinput functionが指定されるとcode_arr[i]
に対応する処理が行われる、といったような処理が行われるので、全く違う位置に加えると大変なことになる。
そしてreader.cppの先ほどの箇所に下のようなcase文を加えた。
case R_NEW_LINE_WITH_BACKSLASH: { const wchar_t *str_to_insert = L" \\\n"; insert_string(data->active_edit_line(), str_to_insert); break; }
デフォルトのキーバインドもconfigファイルに書かれていた...
キーバインドの指定はソースコードに書かれているものだと勝手に思い込み、gdbで追っていったが完全に泥沼だった。 input.cppのinput_mapping_execute_matching_or_generic関数で入力されたキーと登録されているキーバインドの照合が行われているのだが、そのキーバインドがどこで取得されているのかが全く分からず。
結局、気分転換に
$ grep -r yank
を行ったところ、
share/functions/__fish_shared_key_bindings.fish
でデフォルトのキーバインドがconfigとして記述されているのを発見し、yank
などに倣って
bind $argv \co new_line_with_backslash
の記述を足せばいいだけであった。grepすれば2秒で分かる事案であった。
プルリクを送ってみた
機能が実装できたので、Pull Requestを投げてみることにした。
正直、Shift+Enter
で実装できなかったのでなんか微妙だし、機能自体にも賛否両論ありそうなので取り込まれたら良いな、くらいの気持ちで投げてみた。
減るもんじゃないし。
結果、
There's no need to touch the C++ code here: bind \co 'commandline -i
\\\\\n'
.
とのコメントをfishの開発メンバーの方からいただきました。 機能がどうこう、というよりconfigをいじるだけで解決する問題だったみたい。
commandline
というコマンドについて補足すると、これはfish独自のビルトインコマンドで、
$ commandline hoge
を実行すると、次のコマンドラインが
$ hoge
と入力された状態になる、というものだ。 これはキーバインドと組み合わせることで大変便利になり、今回のようにキーバインドを押すことで自動でバックスラッシュと改行が挿入される、というような処理が簡単にconfigで書けるようになるのである。
少し悲しい気持ちもあるが、やっぱりfishは便利だなあという気持ちになった。
感想
- 自分で実装して機能を追加してみるのは楽しい
- デバッガで動作を追うのがつらかった
- シェルの動きを知ることができて勉強になった
- シェルはたいへん
- もう二度とシェル作ってみたいなんて言いません
- 6-7時間くらいは作業していた気がするが、うち実装をしていたのは1時間に届かないくらい。追加した行数も10行に満たない
- 少しの変更でもかなりしっかり読み込まないといけないので大規模ソフトウェアは大変だなあという思いを得た
- 実際に機能を追加する際にはまずはissueを立てて意見を仰ごう(あたりまえ)