2014年3月18日火曜日

capture3とtimeout

Rubyで外部コマンドを実行するには様々な方法があるけど、出力とステータスを取りたい場合はOpen3.capture3を使うと簡単に得られる。

# リファレンスマニュアルのサンプルから引用
require "open3"

o, e, s = Open3.capture3("echo a; sort >&2", :stdin_data=>"foo\nbar\nbaz\n")
p o #=> "a\n"
p e #=> "bar\nbaz\nfoo\n"
p s #=> #<Process::Status: pid 32682 exit 0>

面倒なパイプの制御などをしなくても簡単に標準入出力が扱える。

ところが、capture3では次のようなタイムアウト処理はうまく動かない。

require "timeout"
require "open3"

cmd = "/tmp/heavy.sh"

o, e, s = nil
begin
  Timeout.timeout(3) do
    o, e, s = Open3.capture3(cmd)
  end
rescue Timeout::Error
  puts "execution expired"

  # pidが取れないのでkillできない
  # それどころか、コマンドが終了するまでここに到達しない
end

p o, e, s

上記のスクリプトを実行すると3秒でTimeout::Errorが発生しそうだけど、実際には発生しない。
何故かと言うと、capture3が内部で使用しているpopen_runという実装の中で、次のようにensureでコマンドの終了を待っているから。

  def popen_run(cmd, opts, child_io, parent_io) # :nodoc:
    pid = spawn(*cmd, opts)
    wait_thr = Process.detach(pid)
    child_io.each {|io| io.close }
    result = [*parent_io, wait_thr]
    if defined? yield
      begin
        return yield(*result)
      ensure
        parent_io.each{|io| io.close unless io.closed?}
        wait_thr.join
      end
    end
    result
  end

というわけで、タイムアウトしたら中断したい場合は、この辺を紐解いて自前で同じような実装をすることになる。

この場合だとブロックを渡さなければIOと子プロセスのスレッドが返ってくるので、capture3の実装をブロックを使わないで書いたオレオレバージョンとかを作ればいいかもしれない。
ただしそれだとPIDが取れない(Process::StatusはPIDを持っているけど、これは終了するまで手に入らない)ので、popen_runのあたりも含めて自前で実装するはめになる。

なので、前回の外部コマンドを実行してタイムアウトしたらkillするではあえてspawnを直接使って実装したのでした。

0 件のコメント:

コメントを投稿