Search code examples
rubywindowsiostdin

Forwarding stdin to a subprocess in Ruby on Windows


I am developing a wrapper for Terraform, which at some point during its execution, it may request user input. So, my application must forward everything typed on its stdin to the subprocess' stdin. The following solution works on Linux, but in Windows the subprocess (Terraform) seems to never receive the input:

require 'open3'

def exec(cmd)
  Open3.popen3(cmd) do |stdin, stdout, stderr, thread|
    stdout_thread = Thread.new do
      IO.copy_stream(stdout, STDOUT)
    end

    stderr_thread = Thread.new do
      IO.copy_stream(stderr, STDERR)
    end

    stdin_thread = Thread.new do
      IO.copy_stream(STDIN, stdin)
    end

    puts "Return code: #{thread.value}"

    stdin_thread.join
    stdout_thread.join
    stderr_thread.join
  end
end

exec('terraform destroy')

This solution actually works on Windows when executing some applications that require user input different than Terraform. But, the following two implementations (in Go and Python) are capable of forwarding their stdin to Terraform on Windows. So, it might be the case that my Ruby code has some issue, or perhaps Ruby's implementation for Windows has some limitation when dealing with process execution and input forwarding.

Is anyone aware of such a limitation?

Python example:

import subprocess
import sys

with subprocess.Popen(['terraform', 'destroy'],
                      stdin=sys.stdin, stdout=sys.stdout) as proc:
    proc.wait()

Go example:

package main

import (
    "io"
    "log"
    "os"
    "os/exec"
)

func main() {
    cmd := exec.Command("terraform", "destroy")
    stdin, err := cmd.StdinPipe()
    if err != nil { log.Fatal(err) }

    stdout, err := cmd.StdoutPipe()
    if err != nil { log.Fatal(err) }

    stderr, err := cmd.StderrPipe()
    if err != nil { log.Fatal(err) }

    go func() {
        defer stdout.Close()
        io.Copy(os.Stdout, stdout)
    }()
    go func() {
        defer stderr.Close()
        io.Copy(os.Stderr, stderr)
    }()
    go func() {
        defer stdin.Close()
        io.Copy(stdin, os.Stdin)
    }()

    err = cmd.Run()
    log.Printf("Command finished with error: %v", err)
}

Solution

  • The following code snippet based on IO.popen seems to work. It executes a command, and it returns the command output as an array containing the output lines. Optionally, the output is written to stdout too.

    def run(cmd, directory: Dir.pwd, print_output: true)
      out = IO.popen(cmd, err: %i[child out], chdir: directory) do |io|
        begin
          out = ''
          loop do
            chunk = io.readpartial(4096)
            print chunk if print_output
            out += chunk
          end
        rescue EOFError; end
        out
      end
    
      $?.exitstatus.zero? || (raise "Error running command #{cmd}")
    
      out.split("\n")
         .map { |line| line.tr("\r\n", '') }
    end