philiprehberger-task_runner 0.1.3 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6dfc4d0ba28ecfa8f7595708a8d1ed644a6af53c0ccca5e0ea3088a059ba50e
4
- data.tar.gz: 15256a978c23e405abb6a01b8045c6c03b193f9fcb43f199fd883fe5412482fc
3
+ metadata.gz: ac71950b0f8ccde0b9330b8962d7513e3f630bf12e7bd2b442f3dbacc4176c5d
4
+ data.tar.gz: 4233518a744a4cce5303b43885eeba5fe1eeea69792734ec238f301ffbfab4aa
5
5
  SHA512:
6
- metadata.gz: 55115c901422f69e3fd8e3a0a104c9227a73916675d70587c5203043da7f8e4044c3d35104a88d26d28cde519fc6cedd31d5ef6a672f17959444cf77ef203e1b
7
- data.tar.gz: bb70b1e19dc9dac1381f0203112dab213ef5771d5bc06459e098ec7a563ee5fb04bc79c156b286d2f9a6066613c3a692893e17e333b10398c202aabf1a1c2e8a
6
+ metadata.gz: 7af1c5084052976871ef648652fc5f739c27eebb91c04419a6762bad3c554a37b499637585fa3c58018f8751bf07cef5b05a9b7221933ad198b6c98b213243a2
7
+ data.tar.gz: e42e5cd9fbdcd1e18d0738a2e53d7d77daba4a54fd4de915364bc889efe86c2faeb023024e366ffd0a6e1bab5fef800744711e0a31d2986d032e7fff329398fd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-30
11
+
12
+ ### Added
13
+
14
+ - Signal handling: `run(cmd, signal: :TERM, kill_after: 5)` sends the specified signal on timeout, escalates to SIGKILL after `kill_after` seconds
15
+ - `Result#signal` reports which signal killed the process (`:TERM`, `:KILL`, or `nil`)
16
+ - Input piping: `run(cmd, stdin: "data")` pipes string or IO data to the process's stdin
17
+ - Stderr streaming: two-argument blocks receive `(line, stream)` where stream is `:stdout` or `:stderr`
18
+ - Backward compatible: single-argument blocks still receive only stdout lines
19
+
20
+ ## [0.1.4] - 2026-03-26
21
+
22
+ ### Changed
23
+
24
+ - Add Sponsor badge and fix License link format in README
25
+
10
26
  ## [0.1.3] - 2026-03-24
11
27
 
12
28
  ### Fixed
data/README.md CHANGED
@@ -2,9 +2,14 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-task-runner/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-task-runner/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-task_runner.svg)](https://rubygems.org/gems/philiprehberger-task_runner)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-task-runner)](https://github.com/philiprehberger/rb-task-runner/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-task-runner)](https://github.com/philiprehberger/rb-task-runner/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-task-runner)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-task-runner/bug)](https://github.com/philiprehberger/rb-task-runner/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-task-runner/enhancement)](https://github.com/philiprehberger/rb-task-runner/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
10
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
6
11
 
7
- Shell command runner with output capture, timeout, and streaming
12
+ Shell command runner with output capture, timeout, streaming, signal handling, and stdin piping
8
13
 
9
14
  ## Requirements
10
15
 
@@ -52,6 +57,29 @@ result = Philiprehberger::TaskRunner.run(
52
57
  )
53
58
  ```
54
59
 
60
+ ### Signal Handling
61
+
62
+ ```ruby
63
+ result = Philiprehberger::TaskRunner.run(
64
+ 'long-process',
65
+ timeout: 30,
66
+ signal: :TERM,
67
+ kill_after: 5
68
+ )
69
+ # On timeout: sends SIGTERM first, then SIGKILL after 5 seconds if still running
70
+ # result.signal reports which signal killed the process (:TERM, :KILL, or nil)
71
+ ```
72
+
73
+ ### Input Piping
74
+
75
+ ```ruby
76
+ result = Philiprehberger::TaskRunner.run('cat', stdin: "hello world")
77
+ puts result.stdout # => "hello world"
78
+
79
+ # Also accepts IO objects
80
+ result = Philiprehberger::TaskRunner.run('wc', '-l', stdin: File.open('data.txt'))
81
+ ```
82
+
55
83
  ### Streaming Output
56
84
 
57
85
  ```ruby
@@ -60,17 +88,30 @@ Philiprehberger::TaskRunner.run('tail', '-f', '/var/log/app.log', timeout: 10) d
60
88
  end
61
89
  ```
62
90
 
91
+ ### Stderr Streaming
92
+
93
+ ```ruby
94
+ Philiprehberger::TaskRunner.run('make', 'build') do |line, stream|
95
+ case stream
96
+ when :stdout then puts "OUT: #{line}"
97
+ when :stderr then puts "ERR: #{line}"
98
+ end
99
+ end
100
+ ```
101
+
63
102
  ## API
64
103
 
65
104
  | Method / Class | Description |
66
105
  |----------------|-------------|
67
- | `.run(cmd, *args, timeout:, env:, chdir:)` | Run a command and return a Result |
106
+ | `.run(cmd, *args, timeout:, env:, chdir:, signal:, kill_after:, stdin:)` | Run a command and return a Result |
68
107
  | `.run(cmd) { \|line\| ... }` | Run with line-by-line stdout streaming |
108
+ | `.run(cmd) { \|line, stream\| ... }` | Run with stdout and stderr streaming |
69
109
  | `Result#stdout` | Captured standard output |
70
110
  | `Result#stderr` | Captured standard error |
71
111
  | `Result#exit_code` | Process exit code |
72
112
  | `Result#success?` | Whether exit code is 0 |
73
113
  | `Result#duration` | Execution time in seconds |
114
+ | `Result#signal` | Signal that killed the process (:TERM, :KILL, or nil) |
74
115
 
75
116
  ## Development
76
117
 
@@ -80,6 +121,13 @@ bundle exec rspec
80
121
  bundle exec rubocop
81
122
  ```
82
123
 
124
+ ## Support
125
+
126
+ If you find this package useful, consider giving it a star on GitHub — it helps motivate continued maintenance and development.
127
+
128
+ [![LinkedIn](https://img.shields.io/badge/Philip%20Rehberger-LinkedIn-0A66C2?logo=linkedin)](https://www.linkedin.com/in/philiprehberger)
129
+ [![More packages](https://img.shields.io/badge/more-open%20source%20packages-blue)](https://philiprehberger.com/open-source-packages)
130
+
83
131
  ## License
84
132
 
85
- MIT
133
+ [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
- def initialize(stdout:, stderr:, exit_code:, duration:)
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 == 0
39
+ @exit_code.zero?
35
40
  end
36
41
  end
37
42
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module TaskRunner
5
- VERSION = '0.1.3'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  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 streaming.
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
- # @yield [line] each line of stdout as it arrives (streaming mode)
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
- stdout, stderr, status = if timeout
45
- ::Timeout.timeout(timeout, TimeoutError, 'command timed out') do
46
- Open3.capture3(env_hash, *Array(full_cmd), **spawn_opts)
47
- end
48
- else
49
- Open3.capture3(env_hash, *Array(full_cmd), **spawn_opts)
50
- end
51
-
52
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
53
- Result.new(stdout: stdout, stderr: stderr, exit_code: status.exitstatus || 1, duration: duration)
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
- exit_status = nil
88
+ killed_signal = nil
89
+ block_arity = block.arity
61
90
 
62
- Open3.popen3(env_hash, *Array(full_cmd), **spawn_opts) do |_stdin, stdout, stderr, wait_thr|
63
- _stdin.close
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
- ::Timeout.timeout(timeout, TimeoutError, 'command timed out') do
67
- read_streams(stdout, stderr, stdout_buf, stderr_buf, &block)
68
- exit_status = wait_thr.value
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(stdout, stderr, stdout_buf, stderr_buf, &block)
72
- exit_status = wait_thr.value
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
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
77
- Result.new(
78
- stdout: stdout_buf,
79
- stderr: stderr_buf,
80
- exit_code: exit_status&.exitstatus || 1,
81
- duration: duration
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
- yield line
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
- stderr_buf << stderr.read
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
- private_class_method :run_capture, :run_streaming, :read_streams
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.3
4
+ version: 0.2.0
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-25 00:00:00.000000000 Z
11
+ date: 2026-03-30 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, and line-by-line streaming via blocks.
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, and streaming
55
+ summary: Shell command runner with output capture, timeout, streaming, signal handling,
56
+ and stdin piping
55
57
  test_files: []