philiprehberger-task_runner 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +57 -4
- data/lib/philiprehberger/task_runner/result.rb +7 -2
- data/lib/philiprehberger/task_runner/version.rb +1 -1
- data/lib/philiprehberger/task_runner.rb +138 -38
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fb5481d773646ae680a865d98ac5519d57ca677a53e08ce87b05de1e0db978b
|
|
4
|
+
data.tar.gz: ad16cc33bf847e8a83576e4d794b2554d5c549f370cde3c572a30f0b246e2e5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49d9861467feb9494893096aa551974a02a448db8c42bf2e51ed1c62ee0fb281377afc31d4bec6e49ab317c6803ea22493a8c8437b79af6dfe4b324acb47b074
|
|
7
|
+
data.tar.gz: 22636d10befe17c115036b5336e2e71597ee07c7f381122dc6e5c3e0356c938dbc765505268b0653a6ec4248bda87924593b250e961c062c37eebff23b647018
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-03-31
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Standardize README badges, support section, and license format
|
|
14
|
+
|
|
15
|
+
## [0.2.0] - 2026-03-30
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Signal handling: `run(cmd, signal: :TERM, kill_after: 5)` sends the specified signal on timeout, escalates to SIGKILL after `kill_after` seconds
|
|
20
|
+
- `Result#signal` reports which signal killed the process (`:TERM`, `:KILL`, or `nil`)
|
|
21
|
+
- Input piping: `run(cmd, stdin: "data")` pipes string or IO data to the process's stdin
|
|
22
|
+
- Stderr streaming: two-argument blocks receive `(line, stream)` where stream is `:stdout` or `:stderr`
|
|
23
|
+
- Backward compatible: single-argument blocks still receive only stdout lines
|
|
24
|
+
|
|
10
25
|
## [0.1.4] - 2026-03-26
|
|
11
26
|
|
|
12
27
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/philiprehberger/rb-task-runner/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-task_runner)
|
|
5
|
-
[](https://github.com/sponsors/philiprehberger)
|
|
5
|
+
[](https://github.com/philiprehberger/rb-task-runner/commits/main)
|
|
7
6
|
|
|
8
|
-
Shell command runner with output capture, timeout, and
|
|
7
|
+
Shell command runner with output capture, timeout, streaming, signal handling, and stdin piping
|
|
9
8
|
|
|
10
9
|
## Requirements
|
|
11
10
|
|
|
@@ -53,6 +52,29 @@ result = Philiprehberger::TaskRunner.run(
|
|
|
53
52
|
)
|
|
54
53
|
```
|
|
55
54
|
|
|
55
|
+
### Signal Handling
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
result = Philiprehberger::TaskRunner.run(
|
|
59
|
+
'long-process',
|
|
60
|
+
timeout: 30,
|
|
61
|
+
signal: :TERM,
|
|
62
|
+
kill_after: 5
|
|
63
|
+
)
|
|
64
|
+
# On timeout: sends SIGTERM first, then SIGKILL after 5 seconds if still running
|
|
65
|
+
# result.signal reports which signal killed the process (:TERM, :KILL, or nil)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Input Piping
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
result = Philiprehberger::TaskRunner.run('cat', stdin: "hello world")
|
|
72
|
+
puts result.stdout # => "hello world"
|
|
73
|
+
|
|
74
|
+
# Also accepts IO objects
|
|
75
|
+
result = Philiprehberger::TaskRunner.run('wc', '-l', stdin: File.open('data.txt'))
|
|
76
|
+
```
|
|
77
|
+
|
|
56
78
|
### Streaming Output
|
|
57
79
|
|
|
58
80
|
```ruby
|
|
@@ -61,17 +83,30 @@ Philiprehberger::TaskRunner.run('tail', '-f', '/var/log/app.log', timeout: 10) d
|
|
|
61
83
|
end
|
|
62
84
|
```
|
|
63
85
|
|
|
86
|
+
### Stderr Streaming
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Philiprehberger::TaskRunner.run('make', 'build') do |line, stream|
|
|
90
|
+
case stream
|
|
91
|
+
when :stdout then puts "OUT: #{line}"
|
|
92
|
+
when :stderr then puts "ERR: #{line}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
64
97
|
## API
|
|
65
98
|
|
|
66
99
|
| Method / Class | Description |
|
|
67
100
|
|----------------|-------------|
|
|
68
|
-
| `.run(cmd, *args, timeout:, env:, chdir:)` | Run a command and return a Result |
|
|
101
|
+
| `.run(cmd, *args, timeout:, env:, chdir:, signal:, kill_after:, stdin:)` | Run a command and return a Result |
|
|
69
102
|
| `.run(cmd) { \|line\| ... }` | Run with line-by-line stdout streaming |
|
|
103
|
+
| `.run(cmd) { \|line, stream\| ... }` | Run with stdout and stderr streaming |
|
|
70
104
|
| `Result#stdout` | Captured standard output |
|
|
71
105
|
| `Result#stderr` | Captured standard error |
|
|
72
106
|
| `Result#exit_code` | Process exit code |
|
|
73
107
|
| `Result#success?` | Whether exit code is 0 |
|
|
74
108
|
| `Result#duration` | Execution time in seconds |
|
|
109
|
+
| `Result#signal` | Signal that killed the process (:TERM, :KILL, or nil) |
|
|
75
110
|
|
|
76
111
|
## Development
|
|
77
112
|
|
|
@@ -81,6 +116,24 @@ bundle exec rspec
|
|
|
81
116
|
bundle exec rubocop
|
|
82
117
|
```
|
|
83
118
|
|
|
119
|
+
## Support
|
|
120
|
+
|
|
121
|
+
If you find this project useful:
|
|
122
|
+
|
|
123
|
+
⭐ [Star the repo](https://github.com/philiprehberger/rb-task-runner)
|
|
124
|
+
|
|
125
|
+
🐛 [Report issues](https://github.com/philiprehberger/rb-task-runner/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
|
|
126
|
+
|
|
127
|
+
💡 [Suggest features](https://github.com/philiprehberger/rb-task-runner/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
|
|
128
|
+
|
|
129
|
+
❤️ [Sponsor development](https://github.com/sponsors/philiprehberger)
|
|
130
|
+
|
|
131
|
+
🌐 [All Open Source Projects](https://philiprehberger.com/open-source-packages)
|
|
132
|
+
|
|
133
|
+
💻 [GitHub Profile](https://github.com/philiprehberger)
|
|
134
|
+
|
|
135
|
+
🔗 [LinkedIn Profile](https://www.linkedin.com/in/philiprehberger)
|
|
136
|
+
|
|
84
137
|
## License
|
|
85
138
|
|
|
86
139
|
[MIT](LICENSE)
|
|
@@ -16,22 +16,27 @@ module Philiprehberger
|
|
|
16
16
|
# @return [Float] execution duration in seconds
|
|
17
17
|
attr_reader :duration
|
|
18
18
|
|
|
19
|
+
# @return [Symbol, nil] signal that killed the process (:TERM, :KILL, or nil)
|
|
20
|
+
attr_reader :signal
|
|
21
|
+
|
|
19
22
|
# @param stdout [String]
|
|
20
23
|
# @param stderr [String]
|
|
21
24
|
# @param exit_code [Integer]
|
|
22
25
|
# @param duration [Float]
|
|
23
|
-
|
|
26
|
+
# @param signal [Symbol, nil]
|
|
27
|
+
def initialize(stdout:, stderr:, exit_code:, duration:, signal: nil)
|
|
24
28
|
@stdout = stdout
|
|
25
29
|
@stderr = stderr
|
|
26
30
|
@exit_code = exit_code
|
|
27
31
|
@duration = duration
|
|
32
|
+
@signal = signal
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
# Whether the command exited successfully.
|
|
31
36
|
#
|
|
32
37
|
# @return [Boolean]
|
|
33
38
|
def success?
|
|
34
|
-
@exit_code
|
|
39
|
+
@exit_code.zero?
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
end
|
|
@@ -11,20 +11,26 @@ module Philiprehberger
|
|
|
11
11
|
class Error < StandardError; end
|
|
12
12
|
class TimeoutError < Error; end
|
|
13
13
|
|
|
14
|
-
# Run a shell command with output capture, optional timeout, and
|
|
14
|
+
# Run a shell command with output capture, optional timeout, streaming, signal handling, and stdin piping.
|
|
15
15
|
#
|
|
16
|
-
# When a block is given, each line of stdout is yielded as it arrives.
|
|
16
|
+
# When a block is given, each line of stdout/stderr is yielded as it arrives.
|
|
17
|
+
# If the block accepts two arguments, it receives (line, stream) where stream is :stdout or :stderr.
|
|
18
|
+
# If the block accepts one argument, it receives only the line (stdout lines only, for backward compatibility).
|
|
17
19
|
#
|
|
18
20
|
# @param cmd [String] the command to execute
|
|
19
21
|
# @param args [Array<String>] additional command arguments
|
|
20
22
|
# @param timeout [Numeric, nil] maximum seconds to wait (nil for no timeout)
|
|
21
23
|
# @param env [Hash, nil] environment variables to set
|
|
22
24
|
# @param chdir [String, nil] working directory for the command
|
|
23
|
-
# @
|
|
25
|
+
# @param signal [Symbol] signal to send on timeout (default :TERM)
|
|
26
|
+
# @param kill_after [Numeric] seconds to wait before sending SIGKILL after initial signal (default 5)
|
|
27
|
+
# @param stdin [String, IO, nil] data to pipe to the process's stdin
|
|
28
|
+
# @yield [line, stream] each line as it arrives (streaming mode)
|
|
24
29
|
# @yieldparam line [String] a line of output
|
|
30
|
+
# @yieldparam stream [Symbol] :stdout or :stderr (only if block accepts 2 params)
|
|
25
31
|
# @return [Result] the command result
|
|
26
32
|
# @raise [TimeoutError] if the command exceeds the timeout
|
|
27
|
-
def self.run(cmd, *args, timeout: nil, env: nil, chdir: nil, &block)
|
|
33
|
+
def self.run(cmd, *args, timeout: nil, env: nil, chdir: nil, signal: :TERM, kill_after: 5, stdin: nil, &block)
|
|
28
34
|
full_cmd = args.empty? ? cmd : [cmd, *args]
|
|
29
35
|
spawn_opts = {}
|
|
30
36
|
spawn_opts[:chdir] = chdir if chdir
|
|
@@ -33,70 +39,164 @@ module Philiprehberger
|
|
|
33
39
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
40
|
|
|
35
41
|
if block
|
|
36
|
-
run_streaming(env_hash, full_cmd, spawn_opts, timeout, start_time, &block)
|
|
42
|
+
run_streaming(env_hash, full_cmd, spawn_opts, timeout, start_time, signal, kill_after, stdin, &block)
|
|
37
43
|
else
|
|
38
|
-
run_capture(env_hash, full_cmd, spawn_opts, timeout, start_time)
|
|
44
|
+
run_capture(env_hash, full_cmd, spawn_opts, timeout, start_time, signal, kill_after, stdin)
|
|
39
45
|
end
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# @api private
|
|
43
|
-
def self.run_capture(env_hash, full_cmd, spawn_opts, timeout, start_time)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
def self.run_capture(env_hash, full_cmd, spawn_opts, timeout, start_time, signal, kill_after, stdin_data)
|
|
50
|
+
stdout_buf = +''
|
|
51
|
+
stderr_buf = +''
|
|
52
|
+
killed_signal = nil
|
|
53
|
+
|
|
54
|
+
Open3.popen3(env_hash, *Array(full_cmd), **spawn_opts) do |stdin_io, stdout_io, stderr_io, wait_thr|
|
|
55
|
+
write_stdin(stdin_io, stdin_data)
|
|
56
|
+
|
|
57
|
+
if timeout
|
|
58
|
+
begin
|
|
59
|
+
::Timeout.timeout(timeout) do
|
|
60
|
+
capture_both(stdout_io, stderr_io, stdout_buf, stderr_buf)
|
|
61
|
+
end
|
|
62
|
+
rescue ::Timeout::Error
|
|
63
|
+
killed_signal = terminate_process(wait_thr.pid, signal, kill_after)
|
|
64
|
+
stdout_buf << drain_io(stdout_io)
|
|
65
|
+
stderr_buf << drain_io(stderr_io)
|
|
66
|
+
wait_thr.value
|
|
67
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
68
|
+
raise TimeoutError, 'command timed out'
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
capture_both(stdout_io, stderr_io, stdout_buf, stderr_buf)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
exit_status = wait_thr.value
|
|
75
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
76
|
+
Result.new(
|
|
77
|
+
stdout: stdout_buf, stderr: stderr_buf,
|
|
78
|
+
exit_code: exit_status.exitstatus || 1,
|
|
79
|
+
duration: duration, signal: killed_signal
|
|
80
|
+
)
|
|
81
|
+
end
|
|
54
82
|
end
|
|
55
83
|
|
|
56
84
|
# @api private
|
|
57
|
-
def self.run_streaming(env_hash, full_cmd, spawn_opts, timeout, start_time, &block)
|
|
85
|
+
def self.run_streaming(env_hash, full_cmd, spawn_opts, timeout, start_time, signal, kill_after, stdin_data, &block)
|
|
58
86
|
stdout_buf = +''
|
|
59
87
|
stderr_buf = +''
|
|
60
|
-
|
|
88
|
+
killed_signal = nil
|
|
89
|
+
block_arity = block.arity
|
|
61
90
|
|
|
62
|
-
Open3.popen3(env_hash, *Array(full_cmd), **spawn_opts) do |
|
|
63
|
-
|
|
91
|
+
Open3.popen3(env_hash, *Array(full_cmd), **spawn_opts) do |stdin_io, stdout_io, stderr_io, wait_thr|
|
|
92
|
+
write_stdin(stdin_io, stdin_data)
|
|
64
93
|
|
|
65
94
|
if timeout
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
begin
|
|
96
|
+
::Timeout.timeout(timeout) do
|
|
97
|
+
read_streams(stdout_io, stderr_io, stdout_buf, stderr_buf, block_arity, &block)
|
|
98
|
+
wait_thr.value
|
|
99
|
+
end
|
|
100
|
+
rescue ::Timeout::Error
|
|
101
|
+
killed_signal = terminate_process(wait_thr.pid, signal, kill_after)
|
|
102
|
+
wait_thr.value
|
|
103
|
+
raise TimeoutError, 'command timed out'
|
|
69
104
|
end
|
|
70
105
|
else
|
|
71
|
-
read_streams(
|
|
72
|
-
|
|
106
|
+
read_streams(stdout_io, stderr_io, stdout_buf, stderr_buf, block_arity, &block)
|
|
107
|
+
wait_thr.value
|
|
73
108
|
end
|
|
74
|
-
end
|
|
75
109
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
111
|
+
Result.new(
|
|
112
|
+
stdout: stdout_buf, stderr: stderr_buf,
|
|
113
|
+
exit_code: wait_thr.value.exitstatus || 1,
|
|
114
|
+
duration: duration, signal: killed_signal
|
|
115
|
+
)
|
|
116
|
+
end
|
|
83
117
|
end
|
|
84
118
|
|
|
85
119
|
# @api private
|
|
86
|
-
def self.read_streams(stdout, stderr, stdout_buf, stderr_buf)
|
|
120
|
+
def self.read_streams(stdout, stderr, stdout_buf, stderr_buf, block_arity)
|
|
121
|
+
two_arg = block_arity == 2
|
|
87
122
|
threads = []
|
|
88
123
|
threads << Thread.new do
|
|
89
124
|
stdout.each_line do |line|
|
|
90
125
|
stdout_buf << line
|
|
91
|
-
|
|
126
|
+
if two_arg
|
|
127
|
+
yield line, :stdout
|
|
128
|
+
else
|
|
129
|
+
yield line
|
|
130
|
+
end
|
|
92
131
|
end
|
|
93
132
|
end
|
|
94
133
|
threads << Thread.new do
|
|
95
|
-
|
|
134
|
+
stderr.each_line do |line|
|
|
135
|
+
stderr_buf << line
|
|
136
|
+
yield line, :stderr if two_arg
|
|
137
|
+
end
|
|
96
138
|
end
|
|
97
139
|
threads.each(&:join)
|
|
98
140
|
end
|
|
99
141
|
|
|
100
|
-
|
|
142
|
+
# @api private
|
|
143
|
+
def self.capture_both(stdout_io, stderr_io, stdout_buf, stderr_buf)
|
|
144
|
+
stderr_thread = Thread.new { stderr_buf << stderr_io.read }
|
|
145
|
+
stdout_buf << stdout_io.read
|
|
146
|
+
stderr_thread.join
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def self.write_stdin(stdin_io, stdin_data)
|
|
151
|
+
if stdin_data.nil?
|
|
152
|
+
stdin_io.close
|
|
153
|
+
elsif stdin_data.respond_to?(:read)
|
|
154
|
+
IO.copy_stream(stdin_data, stdin_io)
|
|
155
|
+
stdin_io.close
|
|
156
|
+
else
|
|
157
|
+
stdin_io.write(stdin_data.to_s)
|
|
158
|
+
stdin_io.close
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @api private
|
|
163
|
+
# Sends the specified signal, waits kill_after seconds, then sends SIGKILL if needed.
|
|
164
|
+
# Returns the signal that actually killed the process.
|
|
165
|
+
def self.terminate_process(pid, signal, kill_after)
|
|
166
|
+
begin
|
|
167
|
+
Process.kill(signal.to_s, pid)
|
|
168
|
+
rescue Errno::ESRCH
|
|
169
|
+
return nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + kill_after
|
|
173
|
+
loop do
|
|
174
|
+
begin
|
|
175
|
+
Process.kill(0, pid)
|
|
176
|
+
rescue Errno::ESRCH
|
|
177
|
+
return signal
|
|
178
|
+
end
|
|
179
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
180
|
+
|
|
181
|
+
sleep(0.05)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
Process.kill('KILL', pid)
|
|
186
|
+
:KILL
|
|
187
|
+
rescue Errno::ESRCH
|
|
188
|
+
signal
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @api private
|
|
193
|
+
def self.drain_io(io)
|
|
194
|
+
io.read_nonblock(1_048_576)
|
|
195
|
+
rescue IO::WaitReadable, IOError
|
|
196
|
+
''
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private_class_method :run_capture, :run_streaming, :read_streams, :capture_both, :write_stdin, :terminate_process,
|
|
200
|
+
:drain_io
|
|
101
201
|
end
|
|
102
202
|
end
|
metadata
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-task_runner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Run shell commands with captured stdout/stderr, exit code, duration measurement,
|
|
14
|
-
configurable timeout, environment variables,
|
|
14
|
+
configurable timeout, environment variables, line-by-line streaming, graceful signal
|
|
15
|
+
escalation on timeout, and stdin piping.
|
|
15
16
|
email:
|
|
16
17
|
- me@philiprehberger.com
|
|
17
18
|
executables: []
|
|
@@ -51,5 +52,6 @@ requirements: []
|
|
|
51
52
|
rubygems_version: 3.5.22
|
|
52
53
|
signing_key:
|
|
53
54
|
specification_version: 4
|
|
54
|
-
summary: Shell command runner with output capture, timeout,
|
|
55
|
+
summary: Shell command runner with output capture, timeout, streaming, signal handling,
|
|
56
|
+
and stdin piping
|
|
55
57
|
test_files: []
|