ぶていのログでぶログ

思い出したが吉日

reddish-shell v0.11.0-beta4 開発進捗 | パイプライン、バックグラウンド実行、コマンド置換の実装

最近の開発状況をば。

コミット * e8e8867 Add command substitute * 3e7b5fe Moved internally reserved fd * a0c531f Improve pipeline and background * 7bb1db7 Improve background process and pipeline * 4d39b7f Replace FdSize to RawFd * 1cf7c73 Implemente Pipe connector * 27c37a5 Improve CTRL-C * ba7f90b Allow empty command line. * 2c8190f Improve signal handle * 1cda827 Improve signal handler * f9182a1 Move Wrapper filed from Executor to Context * 66a9a6c Don't use RefCell * 52247e9 Use clap crate * 8322dc4 Add test code to simple_command * d3d3711 Split Status from crate root * b81dca5 Split Location from crate root * c79d287 Re-export struct and function

パイプライン、バックグラウンド実行の再実装完了

ようやくシェルらしくなった。 パイプラインの実装は果てしなくめんどくさい…。 パイプラインを実装するためにはまずコマンドのバックグラウンド実行を実装する必要がある。 バックグラウンド実行は、何も考えずに実装するならwaitpidしないだけなのだけど、そうするとバックグラウンド実行したコマンドがdefunct…ようはゾンビプロセスとして残り続けしまう。 そこで、SIGCHLD*1を受けた時にwaitpidするという仕組みを導入する。 いわゆるシグナルハンドラっというやつである。 システムコールだけでシグナルハンドラを実装するならば、sigaction(2)などを使ったりするが、これもまたsignal safeにしたりシグナルハンドラが割り込みで呼ばれるので色々考慮しないといけなかったり…とこれまためんどくさいので今回はsignal-hook crateを使うことにした。 このsignal-hookにも罠があったのだけどこれは後述…。

これでSIGCHLDを受け取ってwaitpidできるようになったのだけど、これだとファアグラウンドで実行しているコマンドもwaitpidされてしまい、ECHILDが返ってきたり、コマンドの戻り値が取れなかったりして困ってしまうので、signalを受ける専用のスレッドを作ることにした。 このスレッドはSIGCHLDを受けたらwaitpidを実行して、終了ステータスを受け取り、PIDとそのステータスを保持する変数に格納するということをしている。 メインスレッドではその格納された変数の中に、フォアグラウンドで実行しているコマンドのPIDがあるかをチェックするという感じになっている。 ただ、そのまま実行すると常に変数をチェックするためのループ処理をすることになりCPUが使われてしまうので、状態変数(std::sync::Condvar)を使うことで、変数の変更があるまでスリープするという処理にした。 ここまでやってやっとバックグラウンド実行が実装できた。

次にパイプラインの実行だが、以下のような処理の流れになる。

  1. pipe(2)でパイプを作る
  2. パイプ左側のコマンドをバックグラウンド実行する
    • プロセスグループリーダーに設定
    • 1で作成したパイプの書き込み側をfd:1にセット
  3. パイプ右側のコマンドをバックグラウンド実行する
    • 2で作成したプロセスグループに参加
    • 1で作成したパイプの読み込み側をfd:0にセット
  4. 2,3のプロセスグループが終了するのを待つ

文字で書くとこれだけなのだが、ここにもはまりポイントがある。 まず、プロセスグループのリーダーになったプロセスはパイプラインにつながったすべてのプロセスが起動し終わるまで生きていなければならない。 具体的には cmd1 | cmd2 | cmd3 | cmd4 とあった場合、cmd4が起動し終わるまでcmd1の実行を待つ必要がある。 これはなぜかというと、cmd1がプロセスグループリーダーになっているので、このプロセスがいなくなってしまうとプロセスグループが存在しなくなり*2cmd4起動時にsetpgrpがエラーになってしまうからである。 もう1つが、forkによるfile descriptorのコピーである。 1で作成したパイプがコピーされるので、2や3で起動したプロセスにもパイプがコピーされることになる。 これによりパイプによる双方向通信ができるのだけど、パイプライン処理には必要のないパイプもコピーされることになる。 必要のある方向のパイプはfd:0もしくはfd:1にコピーされて、コマンドが終了時に閉じられるのだが、コピーされていない方は残ってしまう。 これによりいつまでもパイプがクローズされないことで、コマンドが終了しなくなり親プロセスのwaitpidが終わらないという自体が起こってしまう。 これを回避するためには、不要なパイプのFDをマークしておいてクローズするという方法がある。 …が、このブログを書いている今O_CLOEXECをつければいいのでは?ということに気がついたので後で試すことにする。

そんなこんなでかなり苦労したがパイプライン処理とバックグラウンド実行を再実装することができたのであった。 やはり、並列処理を書くのは面倒である…。

signal-hookの罠

signal-hookはとても便利なcrateで、シグナルを扱うならこれを使えばかなり楽に扱える。 しかし、私が作成しているシェルという都合上困ったことも発生する。

1. 内部で作成するUnixStreamのソケットのFDが固定である

signal-hookは内部的にUnixStreamを使っている。 コレ自体はシグナルハンドラーの仕組み上使わざるを得ないと思うのだけど、問題はこのときに作成されるFDが一番若い番号になることである。 具体的には、FDの3と4に作成される。 FDの3と4はシェルスクリプトにおいて頻出するFDなのでこれが予約されるととても困ったことになる。 「困るならコマンド実行前に閉じればいいのでは?」と思うかもしれないが、if文などのforkをしない内部的なコマンド(構文)の場合FDを閉じることはできないのである*3。 仕方ないので、signal-hookが用意しているSignalsInfo構造体を独自に再実装して、任意のFDでUnixStreamを開けるようにした

ちなみに、std::os::unix::net::UnixStream::pairでは任意のFDを指定することはできないので、一旦RawFdにしてからnix::unistd::fcntl(F_DUPFD)でFDをコピーしている。 F_DUPFDではdup2のようにFDをコピーするだけなのだが、コピー元のFDが勝手にクローズされるのはUnixStreamだからだろうか・・・?🤔

2. SA_RESTARTを設定している

signal-hookが内部的に叩いているsigaction(2)では、シグナルを受けたときの動作を設定することができる(sa_flags)。 その中にSA_RESTARTというフラグがあるのだが、これを設定しているために困ったことになる。 通常、シグナルを受けるといくつかのシステムコールは中断される(EINTRを返す)。 これにより、例えばreadしているが、SIGINTを受け取ったら強制的に中断して次の処理を行うということができる。 SA_RESTARTを設定している場合、EINTERは発生せずシステムコールは継続されるという動作になっている。 今回再実装したコマンド置換の場合、パイプをつないだプロセスをバックグラウドで実行しておいて、フォアグラウンドではパイプに対してreadを続けるという処理になっている。 このときにSIGINTによる割り込みでreadを検知したいのだが、前述の理由により検知できない状態になっていた。 この問題を解決するために、sigactionを呼び出してSA_RESTARTを外して再登録するというような処理を追加した。

ちなみに、これでもEINTERが発生せずに悩んでいたら、std::io::Readのread_to_end / read_to_stringはEINTER(std::io::ErrorKind::Interrupt)を無視する仕様だった…

https://doc.rust-lang.org/std/io/trait.Read.html#method.read_to_end

次の再実装は

やっとまともになってきたので、特殊変数の実装やビルトインコマンドの実装、そして、ついにmrubyの内蔵あたりをやっていきたい。

なんか他にも書きたいことがあったけど、ダラダラと実装辛かった話を書いていたら長くなったのでこのあたりで。

*1:子プロセスが終了したときに飛んでくるシグナル

*2:正確にはcmd2/cmd3がいるのでプロセスグループは存在するのだが、これらがcmd4より長生きするかわからないし、リーダーを最後まで残しておいた方が制御しやすい

*3:一度signal_hookをcloseして再度開くということならできるが、煩雑な処理だし停止・再開までのシグナルを漏らす可能性があったりと考えることが多いのでやりたくない。また、そもそも、Write側のFDは閉じれるのだが実装ミスなのか仕様なのかRead側のFDが外から閉じることができないのであった…