お魚大好き

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

【祝】fish shellにwaitコマンドがマージされた

はじめに

この記事は、東京大学電気電子・電子情報工学科で行われた「大規模ソフトウェアを手探る」実験においてfish shellのソースコードを読み、機能の追加を行った記録である。 waitコマンドを実装した詳細については、

fish2017.hatenablog.com

を参照のこと。

マージされました

前回送ったプルリクがmasterブランチにマージされました!!!!!!やったね

github.com

素直に嬉しい。

どうやら次期バージョンfish-3.0からwaitコマンドが有効になるらしい。 masterブランチには既にマージ済なので、本家fish-shellのgithubからcloneしてビルドすればwaitコマンドが使えるようになっている...。感動...。

fish-3.0のリリースが待ち遠しい。

加えられた修正

マージされるまでにレビューを受けて修正、といった作業が必要になるのかと思っていたが、そのままマージされて少し驚いた。 しかしよく見てみるとマージ後に若干の修正が加えられているようで、

github.com

上のリンクのようにCMakeやXcodeのためのビルド設定の追加や、関数の引数にvectorを渡すときに参照渡しになっていなかったのを直されたりしている。 また、

github.com

このようにモダンな文法に修正されたりしているようだ。 モダンな文法というのは

job_t *j;
if (j = ...

if (job_t *j = ...

と書く、といった程度のことで、大きな修正は加えられていないようだった。

if文の中でも変数定義できたのか...。

感想など

正直、学生実験としてfish shellを手探ることになった時、マージまでされるような機能を実装できるとは思っていなかった。 大規模ソフトウェアをこうして手探ってみた感想として、必要な知識を揃え、そのソフトウェア特有の慣習を理解すれば、あとは授業で習ったような普通のプログラムが動いているだけなのだなと感じた。

とにかく、マージされて嬉しかったので今は他のソフトウェアにも手を出してみたい気持ちである。

実験開始後すぐは自分の能力で実験内容についていけるのか心配だったが、手厚いサポートと優秀なペアのおかげでメチャクチャなんとかなった。なんとかなりすぎてマージされた。やったー!!! うれしー!!!
テーマ決めから実際の作業まで、いろいろと助言をくださった田浦先生やTAさんには感謝してもしきれません。楽しい実験でした。

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

fish shellに楽に改行できるキーバインドを追加した

概要

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

fish shellについて

fish(Friendly interactive shell)UNIXにおけるシェルの一つ。自分で設定ファイルを書いていく必要のあるbashzshなどと違い、初期設定のままでも多くの便利な機能が使える点が特徴。

やったこと

実験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が動き始め、入出力はそちら側のターミナルでできるようになる。 あるいは、gdbattach機能を使って既に起動してある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'が入力されたことになっていた。 他のキーについても何が取得されていくかを追っていくうちに、どうやらCtrlShiftと同時にあるキーが押された、という情報は制御文字あるいはエスケープシーケンスとして、単一または複数の入力が渡されているようなのである。 しかし、残念なことにShift+Enterには特殊なエスケープシーケンスが割り当てられている様子もなく、調べてみると多くのターミナルの仕様でShift+EnterEnterは区別して送信されない、ということが判明した。

結論としては、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で実装できなかったのでなんか微妙だし、機能自体にも賛否両論ありそうなので取り込まれたら良いな、くらいの気持ちで投げてみた。 減るもんじゃないし。

github.com

結果、

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を立てて意見を仰ごう(あたりまえ)