在 Ruby 中持续从 STDOUT 读取外部进程

我想通过 Ruby 脚本从命令行运行 blender,然后它将逐行处理 blender 给出的输出,以更新 GUI 中的进度条。Blender 是我需要读取其标准输出的外部进程并不重要。

我似乎无法捕获进度消息搅拌器通常打印到 shell 时,搅拌器进程仍在运行,我已经尝试了几种方法。我似乎总是访问搅拌机的标准输出 之后搅拌机已经退出,而不是当它仍然运行。

这是一个失败的尝试的例子。它确实获取并打印出搅拌机输出的前25行,但只有在搅拌机过程结束之后:

blender = nil
t = Thread.new do
blender = open "| blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1"
end
puts "Blender is doing its job now..."
25.times { puts blender.gets}

编辑:

为了更清楚一些,调用 blender 的命令返回 shell 中的输出流,指示进度(第1-16部分完成等)。似乎任何对“获取”输出的调用都会被阻塞,直到搅拌器退出。问题是如何在搅拌器仍在运行时访问这个输出,因为搅拌器将输出打印到 shell 中。

35018 次浏览

STDOUT.flush or STDOUT.sync = true

use IO.popen. This is a good example.

Your code would become something like:

blender = nil
t = Thread.new do
IO.popen("blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1") do |blender|
blender.each do |line|
puts line
end
end
end

Blender probably doesn't print line-breaks until it is ending the program. Instead, it is printing the carriage return character (\r). The easiest solution is probably searching for the magic option which prints line-breaks with the progress indicator.

The problem is that IO#gets (and various other IO methods) use the line break as a delimiter. They will read the stream until they hit the "\n" character (which blender isn't sending).

Try setting the input separator $/ = "\r" or using blender.gets("\r") instead.

BTW, for problems such as these, you should always check puts someobj.inspect or p someobj (both of which do the same thing) to see any hidden characters within the string.

I've had some success in solving this problem of mine. Here are the details, with some explanations, in case anyone having a similar problem finds this page. But if you don't care for details, here's the short answer:

Use PTY.spawn in the following manner (with your own command of course):

require 'pty'
cmd = "blender -b mball.blend -o //renders/ -F JPEG -x 1 -f 1"
begin
PTY.spawn( cmd ) do |stdout, stdin, pid|
begin
# Do stuff with the output here. Just printing to show it works
stdout.each { |line| print line }
rescue Errno::EIO
puts "Errno:EIO error, but this probably just means " +
"that the process has finished giving output"
end
end
rescue PTY::ChildExited
puts "The child process exited!"
end

And here's the long answer, with way too many details:

The real issue seems to be that if a process doesn't explicitly flush its stdout, then anything written to stdout is buffered rather than actually sent, until the process is done, so as to minimize IO (this is apparently an implementation detail of many C libraries, made so that throughput is maximized through less frequent IO). If you can easily modify the process so that it flushes stdout regularly, then that would be your solution. In my case, it was blender, so a bit intimidating for a complete noob such as myself to modify the source.

But when you run these processes from the shell, they display stdout to the shell in real-time, and the stdout doesn't seem to be buffered. It's only buffered when called from another process I believe, but if a shell is being dealt with, the stdout is seen in real time, unbuffered.

This behavior can even be observed with a ruby process as the child process whose output must be collected in real time. Just create a script, random.rb, with the following line:

5.times { |i| sleep( 3*rand ); puts "#{i}" }

Then a ruby script to call it and return its output:

IO.popen( "ruby random.rb") do |random|
random.each { |line| puts line }
end

You'll see that you don't get the result in real-time as you might expect, but all at once afterwards. STDOUT is being buffered, even though if you run random.rb yourself, it isn't buffered. This can be solved by adding a STDOUT.flush statement inside the block in random.rb. But if you can't change the source, you have to work around this. You can't flush it from outside the process.

If the subprocess can print to shell in real-time, then there must be a way to capture this with Ruby in real-time as well. And there is. You have to use the PTY module, included in ruby core I believe (1.8.6 anyways). Sad thing is that it's not documented. But I found some examples of use fortunately.

First, to explain what PTY is, it stands for pseudo terminal. Basically, it allows the ruby script to present itself to the subprocess as if it's a real user who has just typed the command into a shell. So any altered behavior that occurs only when a user has started the process through a shell (such as the STDOUT not being buffered, in this case) will occur. Concealing the fact that another process has started this process allows you to collect the STDOUT in real-time, as it isn't being buffered.

To make this work with the random.rb script as the child, try the following code:

require 'pty'
begin
PTY.spawn( "ruby random.rb" ) do |stdout, stdin, pid|
begin
stdout.each { |line| print line }
rescue Errno::EIO
end
end
rescue PTY::ChildExited
puts "The child process exited!"
end

I don't know whether at the time ehsanul answered the question, there was Open3::pipeline_rw() available yet, but it really makes things simpler.

I don't understand ehsanul's job with Blender, so I made another example with tar and xz. tar will add input file(s) to stdout stream, then xz take that stdout and compress it, again, to another stdout. Our job is to take the last stdout and write it to our final file:

require 'open3'


if __FILE__ == $0
cmd_tar = ['tar', '-cf', '-', '-T', '-']
cmd_xz = ['xz', '-z', '-9e']
list_of_files = [...]


Open3.pipeline_rw(cmd_tar, cmd_xz) do |first_stdin, last_stdout, wait_threads|
list_of_files.each { |f| first_stdin.puts f }
first_stdin.close


# Now start writing to target file
open(target_file, 'wb') do |target_file_io|
while (data = last_stdout.read(1024)) do
target_file_io.write data
end
end # open
end # pipeline_rw
end

Old question, but had similar problems.

Without really changing my Ruby code, one thing that helped was wrapping my pipe with stdbuf, like so:

cmd = "stdbuf -oL -eL -i0  openssl s_client -connect #{xAPI_ADDRESS}:#{xAPI_PORT}"


@xSess = IO.popen(cmd.split " ", mode = "w+")


In my example, the actual command I want to interact with as if it were a shell, is openssl.

-oL -eL tell it to buffer STDOUT and STDERR only upto a newline. Replace L with 0 to unbuffer completely.

This doesn't always work, though: sometimes the target process enforces its own stream buffer type, like another answer pointed out.