Skip to content

Commit 9df8de9

Browse files
author
Mikkel Kamstrup Erlandsen
committed
Release 0.1.3
Primary feature: split_stderr flag to run()
1 parent 399ecb2 commit 9df8de9

5 files changed

+62
-6
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ git.run(:pull, 'origin', 'master', timeout: 2) # override default timeout of 10
5252
git.run(:status) # will raise an error because :status is not in list of allowed commands
5353
```
5454

55+
Redirection and Stderr Handling
56+
-----------
57+
By default stderr is merged into stdout. You can have it split out by passing `split_stderr: true`
58+
to the runner. Eg:
59+
```rb
60+
CommandRunner.run(['ls', '/nosuchdir'], split_stderr: true) # => {out: '', err: 'ls: No such file or directory' ... }
61+
```
62+
63+
If you just want to silence stderr you can override all standard Kernel.spawn options:
64+
```rb
65+
CommandRunner.run(['ls', '/nosuchdir'], options: {err: '/dev/null})
66+
```
67+
5568
Debugging and Logging
5669
---------
5770
If you need insight to what commands you're running you can pass CommandRunner an object responding to ```:puts```

command_runner_ng.gemspec

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Gem::Specification.new do |s|
22
s.name = 'command_runner_ng'
3-
s.version = '0.1.2'
3+
s.version = '0.1.3'
44
s.summary = "Command Runner NG"
55
s.description = "Helper APIs for advanced interactions with subprocesses and shell commands"
66
s.authors = ["Mikkel Kamstrup Erlandsen"]

lib/command_runner.rb

+34-5
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ module CommandRunner
4545
# Fx. redirecting stderr to /dev/null would look like:
4646
# run('ls', 'nosuchfile', options: {:err => "/dev/null"})
4747
#
48+
# For simple case of splitting stderr into a buffer separate from stdout you can pass
49+
# the argument split_stderr: true. This will make an :err entry available in the result.
50+
#
4851
# All Kernel.spawn features, like setting umasks, process group, and are supported through the options hash.
4952
#
5053
# Debugging: To help debugging your app you can set the debug_log parameter. It can be any old object responding
5154
# to :puts. Fx. $stderr, $stdout, or the write end of an IO.pipe. CommandRunnerNG will put some info about
5255
# all process start, stop, and timeouts here. To enable debug logging for all commands call
5356
# CommandRunner.set_debug_log!($stderr) (or with some other object responding to :puts).
5457
#
55-
def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFAULT_OPTIONS)
58+
def self.run(*args, timeout: nil, environment: {}, debug_log: nil, split_stderr: false, options: DEFAULT_OPTIONS)
5659
if debug_log.nil?
5760
debug_log = @@global_debug_log
5861
end
@@ -88,11 +91,19 @@ def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFA
8891
deadline_sequence = [{:deadline => MAX_TIME, :action => 0}]
8992
end
9093

94+
if split_stderr
95+
err_r, err_w = IO.pipe
96+
errbuf = ""
97+
options = options.merge({:err => err_w})
98+
end
99+
91100
# Spawn child, merging stderr into stdout
92101
io = IO.popen(environment, *args, options)
93102
debug_log.puts("CommandRunnerNG spawn: args=#{args}, timeout=#{timeout}, options: #{options}, PID: #{io.pid}") if debug_log.respond_to?(:puts)
94103
data = ""
95104

105+
err_w.close if split_stderr
106+
96107
# Run through all deadlines until command completes.
97108
# We could merge this block into the selecting block above,
98109
# but splitting like this saves us a Process.wait syscall per iteration.
@@ -101,12 +112,18 @@ def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFA
101112
while Time.now < point[:deadline]
102113
if Process.wait(io.pid, Process::WNOHANG)
103114
read_nonblock_safe!(io, data, tick)
115+
read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
104116
result = {:out => data, :status => $?, pid: io.pid}
117+
if split_stderr
118+
result[:err] = errbuf
119+
err_r.close
120+
end
105121
debug_log.puts("CommandRunnerNG exit: PID: #{io.pid}, code: #{result[:status].exitstatus}") if debug_log.respond_to?(:puts)
106122
io.close
107123
return result
108124
elsif !eof
109125
eof = read_nonblock_safe!(io, data, tick)
126+
read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
110127
end
111128
end
112129

@@ -136,9 +153,19 @@ def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFA
136153
end
137154

138155
# Either we didn't have a deadline, or none of the deadlines killed off the child.
156+
loop do
157+
dead = read_nonblock_safe!(io, data, tick)
158+
read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
159+
break if dead
160+
end
139161
Process.wait(io.pid)
140-
read_nonblock_safe!(io, data, tick)
162+
141163
result = {:out => data, :status => $?, pid: io.pid}
164+
if split_stderr
165+
result[:err] = errbuf
166+
err_r.close
167+
end
168+
142169
debug_log.puts("CommandRunnerNG exit: PID: #{io.pid}, code: #{result[:status].exitstatus}") if debug_log.respond_to?(:puts)
143170

144171
io.close
@@ -157,8 +184,8 @@ def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFA
157184
# git.run(:pull, 'origin', 'master')
158185
# git.run(:pull, 'origin', 'master', timeout: 2) # override default timeout of 10
159186
# git.run(:status) # will raise an error because :status is not in list of allowed commands
160-
def self.create(*args, timeout: nil, environment: {}, allowed_sub_commands: [], debug_log: nil, options: DEFAULT_OPTIONS)
161-
CommandInstance.new(args, timeout, environment, allowed_sub_commands, debug_log, options)
187+
def self.create(*args, timeout: nil, environment: {}, allowed_sub_commands: [], debug_log: nil, split_stderr: false, options: DEFAULT_OPTIONS)
188+
CommandInstance.new(args, timeout, environment, allowed_sub_commands, debug_log, split_stderr, options)
162189
end
163190

164191
# Log all command line invocations to a logger object responding to :puts. Set to nil to disable.
@@ -178,7 +205,7 @@ def self.set_debug_log!(logger)
178205

179206
class CommandInstance
180207

181-
def initialize(default_args, default_timeout, default_environment, allowed_sub_commands, debug_log, options)
208+
def initialize(default_args, default_timeout, default_environment, allowed_sub_commands, debug_log, split_stderr, options)
182209
unless default_args.first.is_a? Array
183210
raise "First argument must be an array of command line args. Found #{default_args}"
184211
end
@@ -188,6 +215,7 @@ def initialize(default_args, default_timeout, default_environment, allowed_sub_c
188215
@default_environment = default_environment
189216
@allowed_sub_commands = allowed_sub_commands
190217
@debug_log = debug_log
218+
@split_stderr = split_stderr
191219
@options = options
192220
end
193221

@@ -214,6 +242,7 @@ def run(*args, timeout: nil, environment: {})
214242
timeout: (timeout || @default_timeout),
215243
environment: @default_environment.merge(environment),
216244
debug_log: @debug_log,
245+
split_stderr: @split_stderr,
217246
options: @options)
218247
end
219248

test/test_command_runner.rb

+6
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ def test_debug_log
137137
rd.close
138138
end
139139

140+
def test_split_err
141+
result = CommandRunner.run('echo OUT ; echo ERR 1>&2', split_stderr: true)
142+
assert_equal "OUT\n", result[:out]
143+
assert_equal "ERR\n", result[:err]
144+
end
145+
140146
# Test disabled as it requires thin.
141147
# Most perculiar behaviour have been observed when backgrounding thin through a subshell.
142148
# Note that correct usage would be to daemonize it with -d.

test/test_command_runner_create.rb

+8
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,12 @@ def test_debug_log
9191
ensure
9292
rd.close
9393
end
94+
95+
def test_split_stderr
96+
rb = CommandRunner.create(['ruby', '-e'], split_stderr: true)
97+
result = rb.run('puts "OUT"; $stderr.puts "ERR"')
98+
99+
assert_equal "OUT\n", result[:out]
100+
assert_equal "ERR\n", result[:err]
101+
end
94102
end

0 commit comments

Comments
 (0)