それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

rubyのpopenでエラー出力や終了コードを取得する話。

ruby の popen に関して。

ruby からIO.popenでプロセスを呼び出すときに、「〇〇」が取れない。というブログがいっぱい見つかって、そうじゃないだろ。という気分になったなったのでメモを残すことにする。

そして、Qiitaなどの記事を丸コピした「いかがでしたでしょうか」も溢れているので、検索でちょっと困った。

私は、プロセス起動を頻繁にやっているので、IO.popen を使っているが、本当にわかりにくいので、面倒だけどいまさらだけど、まとめておく。

IO.popen でプロセス起動

IO.popen はプロセスを起動して結果を取得できる。

Rubyは新しく「プロセス」を起動する。文字列で指定された場合は、シェル経由で、配列で指定された場合は子プロセスで起動する。(ただし、文字列で渡してた場合でも単純なコマンドは子プロセスで起動する。リダイレクトやパイプが含まれるコマンド文字列はシェル経由になる)

プロセスを起動するときは、「STDOUTとSTDIN」を指定して起動する。

IO.popenは プロセスとIOをひらく

IOクラスにあることを忘れないで覚えておく。

IOクラスは、出入力を扱う。popenでプロセスと通信経路を開く。

IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)

少しわかりにくいのがIOクラスです。入力も出力もまとめてIOクラスで扱う。入出力をまとめたのがIOクラス。

子プロセスの実行結果を取得する

io = IO.popen(['echo','Hello IO.popen'] ,'r' )
pp io.read
io.close

実行結果

"Hello IO.popen\n"

子プロセスに入力する

子プロセスの標準入力に書き込む場合

io = IO.popen(['cat', '-'] ,'r+' )
io.puts "Hello IO.popen"
io.close_write
pp io.read
io.close

実行結果

"Hello IO.popen\n"

子プロセスへの出入力について。

ここまでの「結果を取得」「プロセスへ入力」をよく見比べてみる。

## 結果を読み込むとき
io = IO.popen(['echo','Hello IO.popen'] ,'r' )
## プロセスにデータを渡し、結果を読み込む
io = IO.popen(['cat', '-'] ,'r+' )

繰り返しになるが、IO.popenはIOクラスである。READもWRITEもどちらも可能です。それがIOクラスである。

IO.popenは ファイルのopen(オープン)と同じ

IO.popenでやってることは、簡単に言えばプロセスへの出入力を開いている。

ファイルの場合をもいだしてみると、ファイルを開くとき open はファイルへの出入力を開いている。

これらは同じことである。同じこととして扱える。プロセスはファイルみたいなものである。

ruby でファイルを開く例

io = open( '/dev/null', 'r+' )
io.puts "Hello"
io.close

ruby のIOでプロセスを開く例

io = IO.popen( 'cat -', 'r+' )
io.puts "Hello"
io.close

これらのコード例を比較する。コードを見ればわかると思うが、ほぼ同じである。

ファイルへの書込読込と、プロセスへの出入力はほぼ同じように扱える。

終了コードの取得

IO.popen で開いたプロセスの終了コードを取るには。$? を使う。

ただし、$?は子プロセスが終了しないと取れない。当たり前だが、終了しないと$?は出現しない。プロセスが終了したら$?が現れる。

では、いつ終了するのか?

それは、子プロセスへの通信経路として確保しているIOインスタンスclose したときです。ソースコードIO#closeしたときです。

IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)

プロセスと、RubyのIOは接続しています。なのでruby側で子プロセスのoutに接続されたIOをCloseしない限り取得できません。

実行例

io = IO.popen( 'cat -', 'r+' )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果

$ bundle exec bin/sample.rb
nil
#<Process::Status: pid 6574 exit 0>

ファイルなら、ファイルをcloseしない限りファイルは開いています。

同じように、closeしない限り popenしたプロセスは開いています。生きています。

子プロセスとRubyの間にパイプがある。

子プロセスとruby の間に接続が確立しているので、システム(Linux)が待っていてくれます。ruby 側がCloseして、子プロセスの出力結果を捨てるまで、読み込み可能なデータとしてOSのシステムコールが出力結果を持っててくれています。rubyからプロセスへの書き込みパイプが開いてるときも同じようにruby側の書き込みを待ってていてくれます。そのためにプロセスは終了しません。

popen引数でオートクローズ

read/write の両方がopenして起動するが、オプション指定で起動時にcloseしています。

'r' の場合は、書き込みの用のパイプは null になって、close状態で起動しています。

'w' の場合は、読み込み用のパイプは null になって、close状態で起動しています。

読み込み専用で、子プロセスを開く

io = IO.popen( 'echo hello', 'r' )
pp io.read
pp  $?
io.close 
pp $?
"hello\n"
nil
#<Process::Status: pid 6637 exit 0>
読み込み専用に書き込むと。。。
io = IO.popen( 'echo hello', 'r' )
io.puts "Hello"

子プロセスが起動するときに、システムコールを使うが、そのシステムコールに読み込み用のパイプだけを渡しているので書き込みができない。

IOError: not opened for writing
  bin/sample.rb:34:in `write'
  bin/sample.rb:34:in `puts'
  bin/sample.rb:34:in `<top (required)>'

書き込み専用で子プロセスを開く(暗黙の出力)

では、書き込み専用にプロセスを開いた場合はどうでしょうか。

この場合は、読み込みをしていないのでcat の結果は無くなりそうです。

io = IO.popen( 'cat -', 'w' )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果

あれ、、、catの結果が出てきます。

bundle exec bin/sample.rb
Hello
nil
#<Process::Status: pid 6679 exit 0>
takuya@DESKTOP-2ALDRO3:/mnt/c/Users/takuya/Desktop/zip-checker$

暗黙の接続 $stdout

popenで開いたプロセスの出力先を指定しない場合、rubyの標準出力である $stdout へ接続されています。指定しない場合は$stdoutです。

書き込み専用にプロセスを開く。

それでは、暗黙の$stdout 接続をしたくないとき、次のようなコマンドを実行することを思いつくかもしれません。

popen('cat - > /dev/null' )

でも、ちょっとまってください。これはシェル呼び出しでsh コマンドが自分を/dev/nullにつないでいます。ruby 側にありません。

そこで、ruby の popen はIOをつないでいることを思い出してみましょう。

IOをつないでいるのですから、/dev/nullを開くのもruby側でやってみましょう。

io = IO.popen( 'cat -', 'w', :out=>open('/dev/null','w') )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果です。

nil
#<Process::Status: pid 6699 exit 0>

どうでしょうか、popenの扱いが少し見えてきた気がしませんか?

暗黙の接続 $stderr

実は、子プロセスのエラー出力も暗黙の了解として、$stderrに接続されています。

ここが、本当にわかりにくい。

暗黙的に popenのエラー出力をは ruby$stderrに接続されています。ここに気づくか気づかないかで、大きく変わってくる。

この暗黙のIO接続に気づくために、先に$stdoutのお話をしました。

エラー出力を捨てる。

子プロセスのエラー出力をを捨ててみましょう。

これも、コマンドでシェル経由にして、次のようなコードが思いつくかもしれません。

popen('ls /no/exits 2> /dev/null' )

でも、これではシェル呼び出しです。

先程のopenを使ったものを応用してみると次のようになります。

io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') )
pp io.read
pp  $?
io.close 
pp $?

ここまでのまとめ

プロセスの起動には、IN/OUTを指定する必要がある。

-->-->(in)子プロセス(out)----> 

IO.popen('cmd')すると、IOクラスに接続される。

IOクラス--->(in)子プロセス(out)---> IOクラス

IO.popen('cmd','r')すると、IOクラスに接続される。 ただし、入力はclosedされた状態で接続される。

IO(closed)--->(in)子プロセス(out)--->IOクラス

IO.popen( 'w' )すると、$stdoutに接続される。

IOクラス--->-->(in)子プロセス(out)--->$stdout

おなじことが、エラー出力にも起きている。

エラー出力を特に指定しない限り、$stderrに接続される

IO(クラス)--->─(in)子プロセス(out)─>$stdout
                  └-──err───> $stderr

ターミナルで起動時との比較

popenは、ターミナルからコマンドを起動したときと同様にプロセスを起動するのだが。popenを使っているとよくわからなくなる。

ターミナルから起動した場合、STDOUT/STDIN/STDERRはどこへ行くのを考える。popenやファイルとよく似ているため、理解の補助になるかもしれない。

ターミナルから echo を起動したとき

$ echo hello world

この場合、パイプは次のようになる。

  • STDIN 使わないので即Close
  • STDOUT ターミナルの出力へ
  • STDERR ターミナルの出力へ

echo を起動したとき、echo の結果は、ターミナルの出力と接続されている。

ターミナル --> in -> echo -> out -> ターミナル

ターミナル(sshなど)はttyでIOを扱うので、こんな感じかな。

tty --> in -> echo -> out -> tty 

popenの場合、これが、rubyになるだけである。

ruby--> in -> echo -> out -> ruby($stdout or IO)

もう、書きませんが、stderr についても同じである。

ruby--> in -> echo -> err -> ruby($stderr)

接続先のはOS(ファイルディスクリプタ)である。

ここで出てきた接続先はファイルディスクリプタです。

プロセスを起動したとき、プロセスを管理しているのはOSです。OSの管理下の接続先のみプロセスは接続(リダイレクト)が可能です。

IOの接続先はOSの管理するファイルディスクリプタなので、IO.popenが接続できるのは、OSの管理下にあるものです。

だから、OS管理下にないRuby内部の文字列(StringIO)などには接続できません

io = IO.popen( 'ls /no/exists', 'r', :err=>StringIO.new('','w') )
ArgumentError: wrong exec redirect action
  bin/sample.rb:33:in `popen'
  bin/sample.rb:33:in `<top (required)>'

次の例では、エラー出力を /dev/nullに接続していますが、これは/dev/nullを開いた、ファイルディスクリプタに接続しているわけです。ここもいくつかrubyが呼び出したときに、OSとの暗黙的な接続が動いてます。このあたりはC言語知らないと、少し辛いかもしれません。

io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') )

簡単に言えば、popenで接続ができるモノは、プロセスから見える範囲にあるモノに限られます。

IO.pipeでつなぐ。

popenは子プロセスが扱える接続先にしか接続できません。それは「ファイル・STDOUT・STDERR」であり、そしてパイプです。

ここまで分かれば、システムコール(OS)管理下にまかせておけば、何でも接続できるとわかる。

その接続先として任意に作れるのがOSの「パイプ」です。OSにパイプを作成してもらうのは、ruby の、IO.pipeが対応している。このメソッドで、OSにパイプを作ってもらうシステムコールを呼び出すことができる。

pp IO.pipe
pp IO.pipe

pipeすると、StringIOとは違い、システムコールによるOS管理下にあるパイプがもらえる。

$ bundle exec bin/sample.rb
[#<IO:fd 9>, #<IO:fd 10>]
[#<IO:fd 11>, #<IO:fd 12>]

fd はファイルディスクリプタを示し、OSの管理するものである。番号は管理番号で、プロセスごとに割り当てられる。

StrngIOは接続できないが「パイプ」なら、popenのエラー出力や標準出力と接続できるのである。IO.pipeの結果は、ファイルディスクリプタであり、open()と同じようにOSファイルとつながっているわけです。IOは奥が深いですね。

子プロセスの出力先を変える

pooen された子プロセスに、IN/OUTがあるとわかった。それらはパイプであり、OSが管理するファイルディスクリプタである。

これらが、よくわかったところで、その出力先を変えてみようと思う。

rubyからファイルディスクリプタを作るには IO.pipe である。

io_r,io_w = IO.pipe
io = IO.popen(['ls', '/a'] , :err => io_w  )
pp io.read
io.close
pp $?

これを、実行すると、エラー出力は消えてしまう。

bundle exec bin/sample.rb
""
#<Process::Status: pid 6850 exit 2>

消えたけど、捨てられたわけはなく、別PIPEに送信されている。

io_r,io_w = IO.pipe
io = IO.popen(['ls', '/a'] , :err => io_w  )
pp io.read
io_w.close
pp io_r.read
io.close
pp $?

closeが増えて見にくいです。

IO.pipeの結果は、それぞれ(r,w) 専用になっている。それぞれつながっているので、 w側をloseしないと、r側で読めないのが面倒ですね。

popenしたIOはclose前に読み込み、一方で、pipeはcloseしないと読めないんでですね。ややこしくなってきた。*1

ファイルとプロセスでの違い

IO.popenは、プロセスを開くとき、プロセス特有の制限を受ける。プロセスはシステムコールで起動する。そのため、プロセスにわたたせるのは、パイプだけである。

エラー出力を取りたいために、知ることが多すぎる。と思います。

popenでは標準エラー出力を取れないと書かれちゃうのも仕方が無いのかもしれません。

でもちゃんとIO使えば取れることがわかりました。良かったね。

まとめ

popenでエラー出力をとる

IO.pipeする。

popenで終了コード(exit status)をとる。

IO.popenをcloseする。

サンプル

err_r,err_w = IO.pipe()
io = IO.popen(['ls','/', '/a'] , :err => err_w  )
##
err_w.close
pp err_r.read
##
pp io.read
io.close

pp $?

出力先を変えるだけなら

io = IO.popen(['ls','/', '/a'] , :err => '/dev/null'  )

もっと詳しく知りたいとき

次の本を読んでください。

関連資料

https://qiita.com/riocampos/items/3d3f540722f91589e00a

https://riocampos-tech.hatenablog.com/entry/20151225/how_to_capture_stderr_by_io_popen_method

https://qiita.com/tyabe/items/56c9fa81ca89088c5627

*1: 正確には、IO#read はEOFまで読み込みを試みるが、#close しないかぎりEOFが来ないので、EOFまちになって止まる。IO#read以外ならclose前でも読める。