socketry/async-io

Capturing stdout one line at a time?

Closed this issue ยท 8 comments

Hi,
How can I capture stdout one line at a time with Async?

Without async I can capture stdout with:

def with_captured_stdout
  original_stdout = $stdout  # capture previous value of $stdout
  $stdout = StringIO.new     # assign a string buffer to $stdout
  yield                      # perform the body of the user code
  $stdout.string             # return the contents of the string buffer
ensure
  $stdout = original_stdout  # restore $stdout to its previous value
end

captured = with_captured_stdout { puts "guava"}
puts "captured: #{captured}"

But I would like to process each line as it's written to stdout. It's for testing a CLI, where I would like to stop a long-running Async task as a soon as a particular output to stdout is seen.

Have you considered using a pipe?

You mean running it as a separate process and using IO.pipe?

I have a solution based on RSpec and Aruba, where the executable is run (presumably in a separate process). The drawback is that it's quite slow, but I guess that's because the output from the executable is not evaluated line-by-line. Instead it's stopped after a certain time, and the entire output is then checked.

Something like this should work:

#!/usr/bin/env ruby

require 'async'

input, output = IO.pipe
$stdout.reopen(output)

Async do
  Async do
    10.times do
      puts "Hello"
      sleep 1
    end
  end
  
  while line = input.gets
    # Log this to stderr otherwise it will go to our pipe:
    $stderr.puts "Got: #{line}"
  end
end

Maybe I can use a thin wrapper around stdout, and use Async just for a timeout:

require 'async'

class Capture
  class Match < StandardError
  end

  attr_reader :original_stdout

  def initialize regex
    @regex = regex
    @original_stdout = $stdout
  end

  def puts s
    @original_stdout.puts s
    raise Match if @regex.match s
  end

  def write s
    @original_stdout.write s
  end
end

def expect_stdout_within regex, timeout:
  Async do |task|
    $stdout = Capture.new regex
    task.with_timeout(timeout) { yield }
  rescue Capture::Match
  end
ensure
  $stdout = $stdout.original_stdout
end

expect_stdout_within /Ready/, timeout: 3.5 do
  puts 'Step 1'; sleep 1
  puts 'Step 2'; sleep 1
  puts 'Ready'; sleep 1
  puts 'Step 3'; sleep 1
  puts 'Step 4'; sleep 1
  puts 'Step 5'; sleep 1
end

Seems to work... As soon as "Ready" is output using puts(), the code stops (ie. the test has passed). If the timeout is reached, an exception can be raised (ie. test has failed).

Didn't see your reply before I posted the above - thank you, will try it!

Yeah your solution seems simpler and more robust. Going to close this issue, it seems there are not really any Async classes needed for capturing stdout. Thank you for your help!

In your example, how would you restore $stdout to it's normal state after calling reopen()?

#!/usr/bin/env ruby

require 'async'

def captured_stdout
  input, output = IO.pipe
  old_stdout = $stdout.dup
  $stdout.reopen(output)
  
  yield input, output
ensure
  if old_stdout
    $stdout.reopen(old_stdout)
  end
end

Async do
  captured_stdout do |input, output|
    reader = Async do
      while line = input.gets
        # Log this to stderr otherwise it will go to our pipe:
        $stderr.puts "Got: #{line}"
      end
    end
    
    10.times do
      puts "Hello"
      sleep 0.1
    end
    reader.stop
  end
  
  puts "Done"
end