Shelling out with Open3
So far in this little shelling-out series we've looked at system, backticks, exec, and spawn.
However, when you need complete control over a subprocess with separate streams for stdin, stdout, and stderr plus explicit process status handling, the Open3 module is the next step up:
require "open3"
stdin, stdout, stderr, process_waiter = Open3.popen3 %q(ruby -e '
input = STDIN.read
puts "OUT: #{input.upcase}"
warn "ERR: bytes=#{input.bytesize}"
')
stdin.puts("hello")
stdin.close # tell child process there is no more input
puts stdout.read # => "OUT: HELLO\n"
puts stderr.read # => "ERR: bytes=6\n"
puts process_waiter.value.exitstatus # => 0
const { spawn } = require("child_process");
const child = spawn("ruby", [
"-e",
`
input = STDIN.read
puts "OUT: #{input.upcase}"
warn "ERR: bytes=#{input.bytesize}"
`
], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => stdout += chunk);
child.stderr.on("data", (chunk) => stderr += chunk);
child.on("close", (code) => {
console.log(stdout); // "OUT: HELLO\n"
console.log(stderr); // "ERR: bytes=6\n"
console.log(code); // 0
});
child.stdin.write("hello\n");
child.stdin.end();
This is a simple example using all of stdin, stdout and stderr. You can imagine more involved ones where you would be streaming the output of the command using stdout.each_line or similar instead. If you're writing to stdin, remember to close it, otherwise the subprocess may keep waiting for more input.
It is common to use popen3 with a block argument instead of capturing the streams as return values. This automatically waits for the process to finish and ensures that all resources are cleaned up:
require "open3"
Open3.popen3("ruby", "-e", %q(
input = STDIN.read
puts "OUT: #{input.upcase}"
warn "ERR: bytes=#{input.bytesize}"
)) do |stdin, stdout, stderr, process_waiter|
stdin.puts("hello")
stdin.close
puts stdout.read # => "OUT: HELLO\n"
puts stderr.read # => "ERR: bytes=6\n"
puts process_waiter.value.exitstatus # => 0
end
When we don't need input or streaming, we can use the simpler Open3.capture3 instead:
require "open3"
stdout, stderr, status = Open3.capture3 %q(ruby -e '
puts "All good"
warn "Oh dear!"
exit 3
')
puts stdout.inspect # => "All good\n"
puts stderr.inspect # => "Oh dear!\n"
puts status.exitstatus # => 3
const { spawnSync } = require("child_process");
const result = spawnSync(
"ruby",
["-e", `
puts "All good"
warn "Oh dear!"
exit 3
`],
{ encoding: "utf-8" }
);
result.stdout; // "All good\n"
result.stderr; // "Oh dear!\n"
result.status; // 3
Open3 contains a few sibling methods worth knowing about as well:
- popen2 / capture2: captures only stdout + status.
- popen2e / capture2e: captures merged stdout/stderr + status.
As with the other shelling-out methods, the safe-arguments rule still applies: pass command and arguments as separate strings to bypass shell execution and prevent injection vulnerabilities.
History
Open3 was added to Ruby in version 1.4, released in 1999, and followed the same API evolution as the other process-spawning methods, including the leading ENV hash argument.
This concludes our look at the various ways to shell out and manage subprocesses in Ruby. All of them, I think, are reasons to love this language. Hope you enjoyed it!