├── .gitignore ├── .yardopts ├── LICENSE ├── README.md ├── exeggutor.gemspec ├── lib └── exeggutor.rb ├── misc ├── left.png └── right.png └── test └── test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | 3 | ## Documentation cache and generated files: 4 | /.yardoc/ 5 | /_yardoc/ 6 | /doc/ 7 | /rdoc/ 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Michael Eisel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exeggutor 🌴 2 | 3 | #### A Simple, Capable, and Unified Interface for Managing Subprocesses in Ruby 4 | 5 | Tired of juggling `system(...)`, `` `...` ``, and `Open3`? Exeggutor provides one simple method that handles many different use cases - safely spawn processes with real-time output, captured stdout/stderr, and sane error handling. 6 | 7 | From this: 8 | 9 | Before using Exeggutor 10 | 11 | To this: 12 | 13 | After using Exeggutor 14 | 15 | #### Examples 16 | 17 | ```ruby 18 | # Copy old_file to #{new_dir}/foo, and raise an exception if it fails 19 | exeg(%W[cp #{old_file} #{new_dir}/foo]) # Exception raised by default on failure 20 | 21 | # Collect stdout from a long-running build task while showing the progress updates as they're 22 | # printed out to stderr 23 | output = exeg(%W[run_build.sh], show_stderr: true).stdout 24 | 25 | # Lots of overrides 26 | result = exeg( 27 | %W[foo bar], 28 | env: {SOME_OVERRIDE: "1"}, # Override env vars 29 | chdir: "/path/to/...", # Run the process in a different working directory 30 | can_fail: true, # Don't throw an exception, handle the error manually or ignore 31 | stdin: "foo", # Send this data to stdin as soon as the process starts 32 | ) 33 | 34 | # Async execution - like popen3, but without its deadlock issue 35 | handle = exeg_async(%W[long_running_process.sh]) 36 | handle.stdin.write("request") 37 | response = handle.stdout.gets 38 | ``` 39 | 40 | #### Overview 41 | 42 | Although Ruby has many different ways of running a subprocess, they all have various drawbacks and quirks. Also, some of the most convenient ways of calling a process, e.g. with backticks, are the most dangerous, because they spawn a subshell. Here's an overview of how Exeggutor solves these shortcomings: 43 | 44 | |Problem with Standard Ruby APIs|Exeggutor Solution| 45 | |-|-| 46 | |Subshells are slow to spawn, error-prone, and insecure | Exeggutor never uses a subshell and always runs processes directly| 47 | |Non-subshells use ugly varargs syntax (e.g. `system('cp', old, "#{new}/foo")`) |Exeggutor encourages elegant %W syntax by taking an array for the arguments parameter (e.g. `exeg(%W[cp #{old} #{new}/foo])`)| 48 | |Process failures are silent, requiring manual checks|Exeggutor raises an exception on failure by default (with rich error context)| 49 | |No simple way to both capture stdout/stderr as strings afterwards and also print them to the shell in real-time |Exeggutor always captures stdout/stderr, and can optionally print them in real-time| 50 | |Different APIs for different use cases|Exeggutor provides a single method for blocking calls and another for non-blocking execution, with smart defaults and flexible named parameters| 51 | 52 | #### Installation 53 | 54 | ``` 55 | gem install exeggutor 56 | ``` 57 | 58 | #### Documentation 59 | 60 | Docs are available [here](https://www.rubydoc.info/gems/exeggutor/toplevel). 61 | -------------------------------------------------------------------------------- /exeggutor.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "exeggutor" 3 | s.version = "0.1.5" 4 | s.summary = "A Simple, Capable, and Unified Interface for Running Subprocesses in Ruby" 5 | # s.description = "A longer description of your gem" 6 | s.authors = ["Michael Eisel"] 7 | s.email = "michael.eisel@gmail.com" 8 | s.files = Dir["lib/**/*.rb"] 9 | s.homepage = "https://github.com/michaeleisel/Exeggutor" 10 | s.license = "MIT" 11 | 12 | s.add_development_dependency "minitest" 13 | end 14 | -------------------------------------------------------------------------------- /lib/exeggutor.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'shellwords' 3 | 4 | module Exeggutor 5 | 6 | # A handle to a process, with IO handles to communicate with it 7 | # and a {ProcessResult} object when it's done. It's largely similar to the array 8 | # of 4 values return by Open3.popen3. However, it doesn't suffer from that library's 9 | # dead-locking issue. For example, even if lots of data has been written to stdout that hasn't been 10 | # read, the subprocess can still write to stdout and stderr without blocking 11 | class ProcessHandle 12 | # @private 13 | def initialize(args, env: nil, chdir: nil) 14 | @stdin_io, @stdout_io, @stderr_io, @wait_thread = Exeggutor::run_popen3(args, env, chdir) 15 | 16 | # Make the streams as synchronous as possible, to minimize the possibility of a surprising lack 17 | # of output 18 | @stdout_io.sync = true 19 | @stderr_io.sync = true 20 | 21 | @stdout_queue = Queue.new 22 | @stderr_queue = Queue.new 23 | 24 | @stdout_pipe_reader, @stdout_pipe_writer = IO.pipe 25 | @stderr_pipe_reader, @stderr_pipe_writer = IO.pipe 26 | 27 | @stdout_write_thread = Thread.new do 28 | loop do 29 | data = @stdout_queue.pop 30 | break if !data # Queue is closed 31 | @stdout_pipe_writer.write(data) 32 | end 33 | @stdout_pipe_writer.close 34 | end 35 | 36 | @stderr_write_thread = Thread.new do 37 | loop do 38 | data = @stderr_queue.pop 39 | break if !data # Queue is closed 40 | @stderr_pipe_writer.write(data) 41 | end 42 | @stderr_pipe_writer.close 43 | end 44 | 45 | # popen3 can deadlock if one stream is written to too much without being read, 46 | # so it's important to continuously read from both streams. This is why 47 | # we can't just let the user call .gets on the streams themselves 48 | @read_thread = Thread.new do 49 | remaining_ios = [@stdout_io, @stderr_io] 50 | while remaining_ios.size > 0 51 | readable_ios, = IO.select(remaining_ios) 52 | for readable_io in readable_ios 53 | begin 54 | data = readable_io.read_nonblock(100_000) 55 | if readable_io == @stdout_io 56 | @stdout_queue.push(data) 57 | else 58 | @stderr_queue.push(data) 59 | end 60 | rescue IO::WaitReadable 61 | # Shouldn't usually happen because IO.select indicated data is ready, but maybe due to EINTR or something 62 | next 63 | rescue EOFError 64 | if readable_io == @stdout_io 65 | @stdout_queue.close 66 | else 67 | @stderr_queue.close 68 | end 69 | remaining_ios.delete(readable_io) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | 76 | # An object containing process metadata and which can be waited on to wait 77 | # until the subprocess ends. Identical to popen3's wait_thr 78 | def wait_thr 79 | @wait_thread 80 | end 81 | 82 | # An IO object for stdin that can be written to 83 | def stdin 84 | @stdin_io 85 | end 86 | 87 | # An IO object for stdout that can be written to 88 | def stdout 89 | @stdout_pipe_reader 90 | end 91 | 92 | # An IO object for stderr that can be written to 93 | def stderr 94 | @stderr_pipe_reader 95 | end 96 | end 97 | 98 | # Represents the result of a process execution. 99 | # 100 | # @attr_reader stdout [String] The standard output of the process. 101 | # @attr_reader stderr [String] The standard error of the process. 102 | # @attr_reader exit_code [Integer] The exit code of the process. 103 | class ProcessResult 104 | attr_reader :stdout, :stderr, :exit_code, :pid 105 | 106 | # @private 107 | def initialize(stdout:, stderr:, exit_code:, pid:) 108 | @stdout = stdout 109 | @stderr = stderr 110 | @exit_code = exit_code 111 | @pid = pid 112 | end 113 | 114 | # Checks if the process was successful. 115 | # 116 | # @return [Boolean] True if the exit code is 0, otherwise false. 117 | def success? 118 | exit_code == 0 119 | end 120 | end 121 | 122 | # Represents an error that occurs during a process execution. 123 | # The error contains a {ProcessResult} object with details about the process. 124 | # 125 | # @attr_reader result {ProcessResult} The result of the process execution. 126 | class ProcessError < StandardError 127 | attr_reader :result 128 | 129 | # @private 130 | def initialize(result) 131 | @result = result 132 | end 133 | end 134 | 135 | # @private 136 | def self.run_popen3(args, env, chdir) 137 | # Use this weird [args[0], args[0]] thing for the case where a command with just one arg is being run 138 | opts = {} 139 | opts[:chdir] = chdir if chdir 140 | if env 141 | Open3.popen3(env, [args[0], args[0]], *args.drop(1), opts) 142 | else 143 | Open3.popen3([args[0], args[0]], *args.drop(1), opts) 144 | end 145 | end 146 | 147 | end 148 | 149 | # Executes a command with the provided arguments and options. Waits for the process to finish. 150 | # 151 | # @param args [Array] The command and its arguments as an array. 152 | # @param can_fail [Boolean] If false, raises a ProcessError on failure. 153 | # @param show_stdout [Boolean] If true, prints stdout to the console in real-time. 154 | # @param show_stderr [Boolean] If true, prints stderr to the console in real-time. 155 | # @param chdir [String, nil] The working directory to run the command in. If nil, uses the current working directory. 156 | # @param stdin [String, nil] Input data to pass to the command's stdin. If nil, doesn't pass any data to stdin. 157 | # @param env [Hash{String => String}, nil] A hashmap containing environment variable overrides, 158 | # or `nil` if no overrides are desired 159 | # 160 | # @return {ProcessResult} An object containing process info such as stdout, stderr, and exit code. 161 | # 162 | # @raise {ProcessError} If the command fails and `can_fail` is false. 163 | def exeg(args, can_fail: false, show_stdout: false, show_stderr: false, env: nil, chdir: nil, stdin_data: nil) 164 | raise "args.size must be >= 1" if args.empty? 165 | 166 | stdin_io, stdout_io, stderr_io, wait_thr = Exeggutor::run_popen3(args, env, chdir) 167 | stdin_io.write(stdin_data) if stdin_data 168 | stdin_io.close 169 | 170 | # Make the streams as synchronous as possible, to minimize the possibility of a surprising lack 171 | # of output 172 | stdout_io.sync = true 173 | stderr_io.sync = true 174 | 175 | stdout = +'' 176 | stderr = +'' 177 | 178 | # Although there could be more code sharing between this and exeg_async, it would either complicate exeg_async's inner workings 179 | # or force us to pay the same performance cost that exeg_async does 180 | remaining_ios = [stdout_io, stderr_io] 181 | while remaining_ios.size > 0 182 | readable_ios, = IO.select(remaining_ios) 183 | for readable_io in readable_ios 184 | begin 185 | data = readable_io.read_nonblock(100_000) 186 | if readable_io == stdout_io 187 | stdout << data 188 | $stdout.print(data) if show_stdout 189 | else 190 | stderr << data 191 | $stderr.print(data) if show_stderr 192 | end 193 | rescue IO::WaitReadable 194 | # Shouldn't usually happen because IO.select indicated data is ready, but maybe due to EINTR or something 195 | next 196 | rescue EOFError 197 | remaining_ios.delete(readable_io) 198 | end 199 | end 200 | end 201 | 202 | result = Exeggutor::ProcessResult.new( 203 | stdout: stdout, 204 | stderr: stderr, 205 | exit_code: wait_thr.value.exitstatus, 206 | pid: wait_thr.pid 207 | ) 208 | if !can_fail && !result.success? 209 | error_str = <<~ERROR_STR 210 | Command failed: #{args.shelljoin} 211 | Exit code: #{result.exit_code} 212 | stdout: #{result.stdout} 213 | stderr: #{result.stderr} 214 | pid: #{result.pid} 215 | ERROR_STR 216 | raise Exeggutor::ProcessError.new(result), error_str 217 | end 218 | 219 | result 220 | end 221 | 222 | # Executes a command with the provided arguments and options. Does not wait for the process to finish. 223 | # 224 | # @param args [Array] The command and its arguments as an array. 225 | # @param chdir [String, nil] The working directory to run the command in. If nil, uses the current working directory. 226 | # @param env [Hash{String => String}, nil] A hashmap containing environment variable overrides, 227 | # or `nil` if no overrides are desired 228 | # 229 | # @return {ProcessHandle} 230 | def exeg_async(args, env: nil, chdir: nil) 231 | Exeggutor::ProcessHandle.new(args, env: env, chdir: chdir) 232 | end 233 | -------------------------------------------------------------------------------- /misc/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeleisel/Exeggutor/79638c5609b99545e844c04fc251e3c4cccc6027/misc/left.png -------------------------------------------------------------------------------- /misc/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeleisel/Exeggutor/79638c5609b99545e844c04fc251e3c4cccc6027/misc/right.png -------------------------------------------------------------------------------- /test/test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require_relative "../lib/exeggutor" 3 | require 'pry-byebug' 4 | 5 | class TestYourGem < Minitest::Test 6 | def test_various 7 | result = exeg(%W[echo hi]) 8 | assert_equal "hi\n", result.stdout 9 | script = 'warn "this is stderr"; puts "this is stdout"; exit 1' 10 | 11 | error = assert_raises(Exeggutor::ProcessError) do 12 | exeg(%W[ruby -e #{script}]) 13 | end 14 | assert_equal "this is stderr\n", error.result.stderr 15 | assert_equal "this is stdout\n", error.result.stdout 16 | 17 | result = exeg(%W[ruby -e #{script}], can_fail: true) 18 | assert_equal "this is stderr\n", error.result.stderr 19 | assert_equal "this is stdout\n", error.result.stdout 20 | 21 | exeg(%W[ruby -e #{script}], show_stdout: true, can_fail: true) 22 | exeg(%W[ruby -e #{script}], show_stderr: true, can_fail: true) 23 | 24 | result = exeg(%W[cat], stdin_data: "hi") 25 | assert_equal "hi", result.stdout 26 | assert_equal true, result.pid > 0 27 | end 28 | 29 | def test_async 30 | return if ENV["EXEG_SKIP_ASYNC"] == "1" 31 | 32 | handle = exeg_async(%W[ruby -e] + ["puts 'foo' ; sleep 1 ; warn 'bar' ; sleep 1 ; print 'done'"]) 33 | assert_equal(handle.stdout.gets, "foo\n") 34 | assert_equal(handle.stderr.gets, "bar\n") 35 | assert_equal(handle.stdout.gets, "done") 36 | assert_equal(handle.stdout.gets, nil) 37 | assert_equal(handle.stderr.gets, nil) 38 | end 39 | 40 | def test_chdir 41 | handle = exeg_async(%W[false]) 42 | assert_equal(handle.wait_thr.value.exitstatus, 1) 43 | 44 | Dir.mktmpdir do |dir| 45 | assert_equal(exeg(%W[pwd], chdir: dir).stdout, "#{File.realpath(dir)}\n") 46 | end 47 | end 48 | end 49 | --------------------------------------------------------------------------------