process_executer 1.2.0 → 2.0.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 +4 -4
- data/.yardopts +0 -2
- data/CHANGELOG.md +20 -0
- data/README.md +50 -19
- data/Rakefile +11 -6
- data/lib/process_executer/errors.rb +134 -0
- data/lib/process_executer/monitored_pipe.rb +46 -9
- data/lib/process_executer/options.rb +17 -14
- data/lib/process_executer/result.rb +160 -0
- data/lib/process_executer/runner.rb +147 -0
- data/lib/process_executer/version.rb +1 -1
- data/lib/process_executer.rb +291 -37
- metadata +8 -10
- data/.tool-versions +0 -1
- data/lib/process_executer/status.rb +0 -45
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 658921c991aa6654711a48a4638e0de6d998f9b5bcdabf8314cad15bb7294a0f
|
4
|
+
data.tar.gz: 7aee46a6fe1459cbd4e0bc9a67895a3c9946741b192e3766937b90ddad416c03
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c25a01c7d819932a0495b18fc332744c860df5d25c8234df15071ac3ee66a9f6bc6b6e0101e2b02bc0fb691af3a7ac5bf9a77a98565b44964abcbc3d63f4451e
|
7
|
+
data.tar.gz: 915c369b55e999c726c22b2e3ef0b377ac0253afd115615d4cd2cb8ae17244c5f042bde4018bf31cb76ec30baf924761cb26e045577959b29e3f7aedcceddb65
|
data/.yardopts
CHANGED
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,26 @@ All notable changes to the process_executer gem will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
+
## v2.0.0 (2025-03-03)
|
9
|
+
|
10
|
+
[Full Changelog](https://github.com/main-branch/process_executer/compare/v1.3.0..v2.0.0)
|
11
|
+
|
12
|
+
Changes since v1.3.0:
|
13
|
+
|
14
|
+
* f0836cc feat: refactor the interface to simplify the gem
|
15
|
+
|
16
|
+
## v1.3.0 (2025-02-26)
|
17
|
+
|
18
|
+
[Full Changelog](https://github.com/main-branch/process_executer/compare/v1.2.0..v1.3.0)
|
19
|
+
|
20
|
+
Changes since v1.2.0:
|
21
|
+
|
22
|
+
* d1e189b build: add Ruby 3.4 to the CI workflow
|
23
|
+
* e805dfc feat: implement ProcessExecuter.run_command
|
24
|
+
* bad822f fix: update the yard build in the rake file and update included files
|
25
|
+
* 6fbdc5e feat: allow #spawn to accept file descriptors for redirection destination
|
26
|
+
* d745685 test: make it so that tests do not give unnecessary output
|
27
|
+
|
8
28
|
## v1.2.0 (2024-10-10)
|
9
29
|
|
10
30
|
[Full Changelog](https://github.com/main-branch/process_executer/compare/v1.1.2..v1.2.0)
|
data/README.md
CHANGED
@@ -11,15 +11,16 @@ Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?log
|
|
11
11
|
[](https://main-branch.slack.com/archives/C07NG2BPG8Y)
|
12
12
|
|
13
13
|
* [Usage](#usage)
|
14
|
-
|
15
|
-
|
14
|
+
* [ProcessExecuter.run](#processexecuterrun)
|
15
|
+
* [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
|
16
|
+
* [ProcessExecuter.spawn\_and\_wait](#processexecuterspawn_and_wait)
|
16
17
|
* [Installation](#installation)
|
17
18
|
* [Contributing](#contributing)
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
19
|
+
* [Reporting Issues](#reporting-issues)
|
20
|
+
* [Developing](#developing)
|
21
|
+
* [Commit message guidelines](#commit-message-guidelines)
|
22
|
+
* [Pull request guidelines](#pull-request-guidelines)
|
23
|
+
* [Releasing](#releasing)
|
23
24
|
* [License](#license)
|
24
25
|
|
25
26
|
## Usage
|
@@ -29,6 +30,36 @@ gem is hosted on RubyGems.org. Read below of an overview and several examples.
|
|
29
30
|
|
30
31
|
This gem contains the following important classes:
|
31
32
|
|
33
|
+
### ProcessExecuter.run
|
34
|
+
|
35
|
+
`ProcessExecuter.run` execute the given command as a subprocess blocking until it is finished.
|
36
|
+
|
37
|
+
A Result object is returned which includes the process's status and output.
|
38
|
+
|
39
|
+
Supports the same features as
|
40
|
+
[Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn).
|
41
|
+
In addition, it (1) blocks until the command has exited, (2) captures stdout and
|
42
|
+
stderr to a buffer or file, and (3) can optionally kill the command if it exceeds
|
43
|
+
a given timeout duration.
|
44
|
+
|
45
|
+
This command takes two forms:
|
46
|
+
|
47
|
+
1. When passing a single string the command is passed to a shell:
|
48
|
+
|
49
|
+
`ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Result}
|
50
|
+
|
51
|
+
2. When passing an array of strings the command is run directly (bypassing the shell):
|
52
|
+
|
53
|
+
`ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Result}
|
54
|
+
|
55
|
+
Argument env, if given, is a hash that affects ENV for the new process; see
|
56
|
+
[Execution
|
57
|
+
Environment](https://docs.ruby-lang.org/en/3.3/Process.html#module-Process-label-Execution+Environment).
|
58
|
+
|
59
|
+
Argument options is a hash of options for the new process; see the options listed below.
|
60
|
+
|
61
|
+
See comprehensive examples in the YARD documentation for this method.
|
62
|
+
|
32
63
|
### ProcessExecuter::MonitoredPipe
|
33
64
|
|
34
65
|
`ProcessExecuter::MonitoredPipe` streams data sent through a pipe to one or more writers.
|
@@ -68,27 +99,27 @@ output_buffer.string #=> "Hello World\n"
|
|
68
99
|
File.read('process.out') #=> "Hello World\n"
|
69
100
|
```
|
70
101
|
|
71
|
-
Since the data is streamed, any object that implements `#write` can be used. For
|
72
|
-
you can use it to parse process output as a stream which might be useful for
|
73
|
-
or JSON output.
|
102
|
+
Since the data is streamed, any object that implements `#write` can be used. For
|
103
|
+
insance, you can use it to parse process output as a stream which might be useful for
|
104
|
+
long XML or JSON output.
|
74
105
|
|
75
|
-
### ProcessExecuter.
|
106
|
+
### ProcessExecuter.spawn_and_wait
|
76
107
|
|
77
108
|
`ProcessExecuter.spawn` has the same interface as `Process.spawn` but has two
|
78
109
|
important behaviorial differences:
|
79
110
|
|
80
111
|
1. It blocks until the subprocess finishes
|
81
|
-
2. A timeout can be specified using the `:
|
112
|
+
2. A timeout can be specified using the `:timeout_after` option
|
82
113
|
|
83
|
-
If the command does not terminate before the
|
84
|
-
sending it the SIGKILL signal. The
|
85
|
-
return `true`. For example:
|
114
|
+
If the command does not terminate before the number of seconds specified by
|
115
|
+
`:timeout_after`, the process is killed by sending it the SIGKILL signal. The
|
116
|
+
returned Result object's `timed_out?` attribute will return `true`. For example:
|
86
117
|
|
87
118
|
```ruby
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
119
|
+
result = ProcessExecuter.spawn_and_wait('sleep 10', timeout_after: 0.01)
|
120
|
+
result.signaled? #=> true
|
121
|
+
result.termsig #=> 9
|
122
|
+
result.timed_out? #=> true
|
92
123
|
```
|
93
124
|
|
94
125
|
## Installation
|
data/Rakefile
CHANGED
@@ -7,7 +7,7 @@ desc 'Run the same tasks that the CI build will run'
|
|
7
7
|
if RUBY_PLATFORM == 'java'
|
8
8
|
task default: %w[spec rubocop bundle:audit build]
|
9
9
|
else
|
10
|
-
task default: %w[spec rubocop yard
|
10
|
+
task default: %w[spec rubocop yard bundle:audit build]
|
11
11
|
end
|
12
12
|
|
13
13
|
# Bundler Audit
|
@@ -50,28 +50,33 @@ RuboCop::RakeTask.new
|
|
50
50
|
# YARD
|
51
51
|
|
52
52
|
unless RUBY_PLATFORM == 'java'
|
53
|
-
#
|
53
|
+
# yard:build
|
54
54
|
|
55
55
|
require 'yard'
|
56
|
-
|
57
|
-
|
56
|
+
|
57
|
+
YARD::Rake::YardocTask.new('yard:build') do |t|
|
58
|
+
t.files = %w[lib/**/*.rb]
|
59
|
+
t.stats_options = ['--list-undoc']
|
58
60
|
end
|
59
61
|
|
60
62
|
CLEAN << '.yardoc'
|
61
63
|
CLEAN << 'doc'
|
62
64
|
|
63
|
-
#
|
65
|
+
# yard:audit
|
64
66
|
|
65
67
|
desc 'Run yardstick to show missing YARD doc elements'
|
66
68
|
task :'yard:audit' do
|
67
69
|
sh "yardstick 'lib/**/*.rb'"
|
68
70
|
end
|
69
71
|
|
70
|
-
#
|
72
|
+
# yard:coverage
|
71
73
|
|
72
74
|
require 'yardstick/rake/verify'
|
73
75
|
|
74
76
|
Yardstick::Rake::Verify.new(:'yard:coverage') do |verify|
|
75
77
|
verify.threshold = 100
|
78
|
+
verify.require_exact_threshold = false
|
76
79
|
end
|
80
|
+
|
81
|
+
task yard: %i[yard:build yard:audit yard:coverage]
|
77
82
|
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Layout/LineLength
|
4
|
+
|
5
|
+
module ProcessExecuter
|
6
|
+
# Base class for all ProcessExecuter::Command errors
|
7
|
+
#
|
8
|
+
# It is recommended to rescue `ProcessExecuter::Error` to catch any
|
9
|
+
# runtime error raised by this gem unless you need more specific error handling.
|
10
|
+
#
|
11
|
+
# Custom errors are arranged in the following class hierarchy:
|
12
|
+
#
|
13
|
+
# ```text
|
14
|
+
# ::StandardError
|
15
|
+
# └─> Error
|
16
|
+
# ├─> CommandError
|
17
|
+
# │ ├─> FailedError
|
18
|
+
# │ └─> SignaledError
|
19
|
+
# │ └─> TimeoutError
|
20
|
+
# └─> ProcessIOError
|
21
|
+
# ```
|
22
|
+
#
|
23
|
+
# | Error Class | Description |
|
24
|
+
# | --- | --- |
|
25
|
+
# | `Error` | This catch-all error serves as the base class for other custom errors. |
|
26
|
+
# | `CommandError` | A subclass of this error is raised when there is a problem executing a command. |
|
27
|
+
# | `FailedError` | Raised when the command exits with a non-zero status code. |
|
28
|
+
# | `SignaledError` | Raised when the command is terminated as a result of receiving a signal. This could happen if the process is forcibly terminated or if there is a serious system error. |
|
29
|
+
# | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. Raised when the operation takes longer than the specified timeout duration (if provided). |
|
30
|
+
# | `ProcessIOError` | Raised when an error was encountered reading or writing to the command's subprocess. |
|
31
|
+
#
|
32
|
+
# @example Rescuing any error
|
33
|
+
# begin
|
34
|
+
# ProcessExecuter.run_command('git', 'status')
|
35
|
+
# rescue ProcessExecuter::Error => e
|
36
|
+
# puts "An error occurred: #{e.message}"
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# @example Rescuing a timeout error
|
40
|
+
# begin
|
41
|
+
# timeout_after = 0.1 # seconds
|
42
|
+
# ProcessExecuter.run_command('sleep', '1', timeout_after:)
|
43
|
+
# rescue ProcessExecuter::TimeoutError => e # Catch the more specific error first!
|
44
|
+
# puts "Command took too long and timed out: #{e}"
|
45
|
+
# rescue ProcessExecuter::Error => e
|
46
|
+
# puts "Some other error occured: #{e}"
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# @api public
|
50
|
+
#
|
51
|
+
class Error < ::StandardError; end
|
52
|
+
|
53
|
+
# Raised when a command fails or exits because of an uncaught signal
|
54
|
+
#
|
55
|
+
# The command executed, status, stdout, and stderr are available from this
|
56
|
+
# object.
|
57
|
+
#
|
58
|
+
# The Gem will raise a more specific error for each type of failure:
|
59
|
+
#
|
60
|
+
# * {FailedError}: when the command exits with a non-zero status
|
61
|
+
# * {SignaledError}: when the command exits because of an uncaught signal
|
62
|
+
# * {TimeoutError}: when the command times out
|
63
|
+
#
|
64
|
+
# @api public
|
65
|
+
#
|
66
|
+
class CommandError < ProcessExecuter::Error
|
67
|
+
# Create a CommandError object
|
68
|
+
#
|
69
|
+
# @example
|
70
|
+
# `exit 1` # set $? appropriately for this example
|
71
|
+
# result = ProcessExecuter::Result.new(%w[git status], $?, 'stdout', 'stderr')
|
72
|
+
# error = ProcessExecuter::CommandError.new(result)
|
73
|
+
# error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
|
74
|
+
#
|
75
|
+
# @param result [Result] The result of the command including the command,
|
76
|
+
# status, stdout, and stderr
|
77
|
+
#
|
78
|
+
def initialize(result)
|
79
|
+
@result = result
|
80
|
+
super(error_message)
|
81
|
+
end
|
82
|
+
|
83
|
+
# The human readable representation of this error
|
84
|
+
#
|
85
|
+
# @example
|
86
|
+
# error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
|
87
|
+
#
|
88
|
+
# @return [String]
|
89
|
+
#
|
90
|
+
def error_message
|
91
|
+
"#{result.command}, status: #{result}, stderr: #{result.stderr.inspect}"
|
92
|
+
end
|
93
|
+
|
94
|
+
# @attribute [r] result
|
95
|
+
#
|
96
|
+
# The result of the command including the command, its status and its output
|
97
|
+
#
|
98
|
+
# @example
|
99
|
+
# error.result #=> #<ProcessExecuter::Result:0x00007f9b1b8b3d20>
|
100
|
+
#
|
101
|
+
# @return [Result]
|
102
|
+
#
|
103
|
+
attr_reader :result
|
104
|
+
end
|
105
|
+
|
106
|
+
# Raised when the command returns a non-zero exitstatus
|
107
|
+
#
|
108
|
+
# @api public
|
109
|
+
#
|
110
|
+
class FailedError < ProcessExecuter::CommandError; end
|
111
|
+
|
112
|
+
# Raised when the command exits because of an uncaught signal
|
113
|
+
#
|
114
|
+
# @api public
|
115
|
+
#
|
116
|
+
class SignaledError < ProcessExecuter::CommandError; end
|
117
|
+
|
118
|
+
# Raised when the command takes longer than the configured timeout_after
|
119
|
+
#
|
120
|
+
# @example
|
121
|
+
# result.timed_out? #=> true
|
122
|
+
#
|
123
|
+
# @api public
|
124
|
+
#
|
125
|
+
class TimeoutError < ProcessExecuter::SignaledError; end
|
126
|
+
|
127
|
+
# Raised when the output of a command can not be read
|
128
|
+
#
|
129
|
+
# @api public
|
130
|
+
#
|
131
|
+
class ProcessIOError < ProcessExecuter::Error; end
|
132
|
+
end
|
133
|
+
|
134
|
+
# rubocop:enable Layout/LineLength
|
@@ -281,19 +281,56 @@ module ProcessExecuter
|
|
281
281
|
# @api private
|
282
282
|
def monitor_pipe
|
283
283
|
new_data = pipe_reader.read_nonblock(chunk_size)
|
284
|
-
|
285
|
-
# :nocov:
|
286
|
-
begin
|
287
|
-
# :nocov:
|
288
|
-
writers.each { |w| w.write(new_data) }
|
289
|
-
rescue StandardError => e
|
290
|
-
@exception = e
|
291
|
-
@state = :closing
|
292
|
-
end
|
284
|
+
write_data(new_data)
|
293
285
|
rescue IO::WaitReadable
|
294
286
|
pipe_reader.wait_readable(0.001)
|
295
287
|
end
|
296
288
|
|
289
|
+
# Check if the writer is a file descriptor
|
290
|
+
#
|
291
|
+
# @param writer [#write] the writer to check
|
292
|
+
# @return [Boolean] true if the writer is a file descriptor
|
293
|
+
# @api private
|
294
|
+
def file_descriptor?(writer) = writer.is_a?(Integer) || writer.is_a?(Symbol)
|
295
|
+
|
296
|
+
# Write the data read from the pipe to all destinations
|
297
|
+
#
|
298
|
+
# If an exception is raised by a writer, set the state to `:closing`
|
299
|
+
# so that the pipe can be closed.
|
300
|
+
#
|
301
|
+
# @param data [String] the data read from the pipe
|
302
|
+
# @return [void]
|
303
|
+
# @api private
|
304
|
+
def write_data(data)
|
305
|
+
writers.each do |w|
|
306
|
+
file_descriptor?(w) ? write_data_to_fd(w, data) : w.write(data)
|
307
|
+
end
|
308
|
+
rescue StandardError => e
|
309
|
+
@exception = e
|
310
|
+
@state = :closing
|
311
|
+
end
|
312
|
+
|
313
|
+
# Write data to the given file_descriptor correctly handling stdout and stderr
|
314
|
+
# @param file_descriptor [Integer, Symbol] the file descriptor to write to (either an integer or :out or :err)
|
315
|
+
# @param data [String] the data to write
|
316
|
+
# @return [void]
|
317
|
+
# @api private
|
318
|
+
def write_data_to_fd(file_descriptor, data)
|
319
|
+
# The case line is not marked as not covered only when using TruffleRuby
|
320
|
+
# :nocov:
|
321
|
+
case file_descriptor
|
322
|
+
# :nocov:
|
323
|
+
when :out, 1
|
324
|
+
$stdout.write data
|
325
|
+
when :err, 2
|
326
|
+
$stderr.write data
|
327
|
+
else
|
328
|
+
io = IO.open(file_descriptor, mode: 'a', autoclose: false)
|
329
|
+
io.write(data)
|
330
|
+
io.close
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
297
334
|
# Read any remaining data from the pipe and close it
|
298
335
|
#
|
299
336
|
# @return [void]
|
@@ -7,7 +7,7 @@ module ProcessExecuter
|
|
7
7
|
#
|
8
8
|
# Valid options are those accepted by Process.spawn plus the following additions:
|
9
9
|
#
|
10
|
-
# * `:
|
10
|
+
# * `:timeout_after`: the number of seconds to allow a process to run before killing it
|
11
11
|
#
|
12
12
|
# @api public
|
13
13
|
#
|
@@ -30,7 +30,7 @@ module ProcessExecuter
|
|
30
30
|
# to `Process.spawn`
|
31
31
|
#
|
32
32
|
NON_SPAWN_OPTIONS = %i[
|
33
|
-
|
33
|
+
timeout_after raise_errors
|
34
34
|
].freeze
|
35
35
|
|
36
36
|
# Any `SPAWN_OPTIONS` set to `NOT_SET` will not be passed to `Process.spawn`
|
@@ -50,7 +50,8 @@ module ProcessExecuter
|
|
50
50
|
umask: NOT_SET,
|
51
51
|
close_others: NOT_SET,
|
52
52
|
chdir: NOT_SET,
|
53
|
-
|
53
|
+
raise_errors: true,
|
54
|
+
timeout_after: nil
|
54
55
|
}.freeze
|
55
56
|
|
56
57
|
# :nocov:
|
@@ -71,14 +72,14 @@ module ProcessExecuter
|
|
71
72
|
# Create a new Options object
|
72
73
|
#
|
73
74
|
# @example
|
74
|
-
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr,
|
75
|
+
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
|
75
76
|
#
|
76
77
|
# @param options [Hash] Process.spawn options plus additional options listed below.
|
77
78
|
#
|
78
79
|
# See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
|
79
80
|
# for a list of valid options that can be passed to `Process.spawn`.
|
80
81
|
#
|
81
|
-
# @option options [Integer, Float, nil] :
|
82
|
+
# @option options [Integer, Float, nil] :timeout_after
|
82
83
|
# Number of seconds to wait for the process to terminate. Any number
|
83
84
|
# may be used, including Floats to specify fractional seconds. A value of 0 or nil
|
84
85
|
# will allow the process to run indefinitely.
|
@@ -92,7 +93,7 @@ module ProcessExecuter
|
|
92
93
|
# Returns the options to be passed to Process.spawn
|
93
94
|
#
|
94
95
|
# @example
|
95
|
-
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr,
|
96
|
+
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
|
96
97
|
# options.spawn_options # => { out: $stdout, err: $stderr }
|
97
98
|
#
|
98
99
|
# @return [Hash]
|
@@ -130,22 +131,24 @@ module ProcessExecuter
|
|
130
131
|
raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
|
131
132
|
end
|
132
133
|
|
133
|
-
# Raise an error if
|
134
|
+
# Raise an error if timeout_after is not a non-negative real number
|
134
135
|
# @return [void]
|
135
|
-
# @raise [ArgumentError] if
|
136
|
+
# @raise [ArgumentError] if timeout_after is not a non-negative real number
|
136
137
|
# @api private
|
137
138
|
def assert_timeout_is_valid
|
138
|
-
return if @options[:
|
139
|
-
return if @options[:
|
139
|
+
return if @options[:timeout_after].nil?
|
140
|
+
return if @options[:timeout_after].is_a?(Numeric) &&
|
141
|
+
@options[:timeout_after].real? &&
|
142
|
+
!@options[:timeout_after].negative?
|
140
143
|
|
141
|
-
raise ArgumentError,
|
144
|
+
raise ArgumentError, invalid_timeout_after_message
|
142
145
|
end
|
143
146
|
|
144
|
-
# The message to be used when raising an error for an invalid
|
147
|
+
# The message to be used when raising an error for an invalid timeout_after
|
145
148
|
# @return [String]
|
146
149
|
# @api private
|
147
|
-
def
|
148
|
-
"
|
150
|
+
def invalid_timeout_after_message
|
151
|
+
"timeout_after must be nil or a non-negative real number but was #{options[:timeout_after].pretty_inspect}"
|
149
152
|
end
|
150
153
|
|
151
154
|
# Determine if the given option is a valid option
|
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'delegate'
|
4
|
+
|
5
|
+
module ProcessExecuter
|
6
|
+
# A decorator for Process::Status that adds the following attributes:
|
7
|
+
#
|
8
|
+
# * `command`: the command that was used to spawn the process
|
9
|
+
# * `options`: the options that were used to spawn the process
|
10
|
+
# * `elapsed_time`: the secs the command ran
|
11
|
+
# * `stdout`: the captured stdout output
|
12
|
+
# * `stderr`: the captured stderr output
|
13
|
+
# * `timed_out?`: true if the process timed out
|
14
|
+
#
|
15
|
+
# @api public
|
16
|
+
#
|
17
|
+
class Result < SimpleDelegator
|
18
|
+
# Create a new Result object
|
19
|
+
#
|
20
|
+
# @param status [Process::Status] the status to delegate to
|
21
|
+
# @param command [Array] the command that was used to spawn the process
|
22
|
+
# @param options [ProcessExecuter::Options] the options that were used to spawn the process
|
23
|
+
# @param timed_out [Boolean] true if the process timed out
|
24
|
+
# @param elapsed_time [Numeric] the secs the command ran
|
25
|
+
#
|
26
|
+
# @example
|
27
|
+
# command = ['sleep 1']
|
28
|
+
# options = ProcessExecuter::Options.new(timeout_after: 0.5)
|
29
|
+
# start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
30
|
+
# timed_out = false
|
31
|
+
# status = nil
|
32
|
+
# pid = Process.spawn(*command, **options.spawn_options)
|
33
|
+
# Timeout.timeout(options.timeout_after) do
|
34
|
+
# _pid, status = Process.wait2(pid)
|
35
|
+
# rescue Timeout::Error
|
36
|
+
# Process.kill('KILL', pid)
|
37
|
+
# timed_out = true
|
38
|
+
# _pid, status = Process.wait2(pid)
|
39
|
+
# end
|
40
|
+
# elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
41
|
+
#
|
42
|
+
# ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:)
|
43
|
+
#
|
44
|
+
# @api public
|
45
|
+
#
|
46
|
+
def initialize(status, command:, options:, timed_out:, elapsed_time:)
|
47
|
+
super(status)
|
48
|
+
@command = command
|
49
|
+
@options = options
|
50
|
+
@timed_out = timed_out
|
51
|
+
@elapsed_time = elapsed_time
|
52
|
+
end
|
53
|
+
|
54
|
+
# The command that was used to spawn the process
|
55
|
+
# @see Process.spawn
|
56
|
+
# @example
|
57
|
+
# result.command #=> [{ 'GIT_DIR' => '/path/to/repo' }, 'git', 'status']
|
58
|
+
# @return [Array]
|
59
|
+
attr_reader :command
|
60
|
+
|
61
|
+
# The options that were used to spawn the process
|
62
|
+
# @see Process.spawn
|
63
|
+
# @example
|
64
|
+
# result.options #=> { chdir: '/path/to/repo', timeout_after: 0.5 }
|
65
|
+
# @return [Hash]
|
66
|
+
# @api public
|
67
|
+
attr_reader :options
|
68
|
+
|
69
|
+
# The secs the command ran
|
70
|
+
# @example
|
71
|
+
# result.elapsed_time #=> 10
|
72
|
+
# @return [Numeric, nil]
|
73
|
+
# @api public
|
74
|
+
attr_reader :elapsed_time
|
75
|
+
|
76
|
+
# @!attribute [r] timed_out?
|
77
|
+
# True if the process timed out and was sent the SIGKILL signal
|
78
|
+
# @example
|
79
|
+
# result = ProcessExecuter.spawn('sleep 10', timeout_after: 0.01)
|
80
|
+
# result.timed_out? # => true
|
81
|
+
# @return [Boolean]
|
82
|
+
#
|
83
|
+
def timed_out? = @timed_out
|
84
|
+
|
85
|
+
# Overrides the default success? method to return nil if the process timed out
|
86
|
+
#
|
87
|
+
# This is because when a timeout occurs, Windows will still return true.
|
88
|
+
#
|
89
|
+
# @example
|
90
|
+
# result = ProcessExecuter.spawn('sleep 10', timeout_after: 0.01)
|
91
|
+
# result.success? # => nil
|
92
|
+
# @return [true, nil]
|
93
|
+
#
|
94
|
+
def success?
|
95
|
+
return nil if timed_out? # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
96
|
+
|
97
|
+
super
|
98
|
+
end
|
99
|
+
|
100
|
+
# Return a string representation of the result
|
101
|
+
# @example
|
102
|
+
# result.to_s #=> "pid 70144 SIGKILL (signal 9) timed out after 10s"
|
103
|
+
# @return [String]
|
104
|
+
def to_s
|
105
|
+
"#{super}#{timed_out? ? " timed out after #{options.timeout_after}s" : ''}"
|
106
|
+
end
|
107
|
+
|
108
|
+
# Return the captured stdout output
|
109
|
+
#
|
110
|
+
# This output is only returned if the `:out` option was set to a
|
111
|
+
# `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
|
112
|
+
# method (e.g. a StringIO).
|
113
|
+
#
|
114
|
+
# @example
|
115
|
+
# # Note that `ProcessExecuter.run` will wrap the given out: object in a
|
116
|
+
# # ProcessExecuter::MonitoredPipe
|
117
|
+
# result = ProcessExecuter.run('echo hello': out: StringIO.new)
|
118
|
+
# result.stdout #=> "hello\n"
|
119
|
+
#
|
120
|
+
# @return [String, nil]
|
121
|
+
#
|
122
|
+
def stdout
|
123
|
+
Array(options.out).each do |pipe|
|
124
|
+
next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
|
125
|
+
|
126
|
+
pipe.writers.each do |writer|
|
127
|
+
return writer.string if writer.respond_to?(:string)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
nil
|
132
|
+
end
|
133
|
+
|
134
|
+
# Return the captured stderr output
|
135
|
+
#
|
136
|
+
# This output is only returned if the `:err` option was set to a
|
137
|
+
# `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
|
138
|
+
# method (e.g. a StringIO).
|
139
|
+
#
|
140
|
+
# @example
|
141
|
+
# # Note that `ProcessExecuter.run` will wrap the given err: object in a
|
142
|
+
# # ProcessExecuter::MonitoredPipe
|
143
|
+
# result = ProcessExecuter.run('echo ERROR 1>&2', err: StringIO.new)
|
144
|
+
# resuilt.stderr #=> "ERROR\n"
|
145
|
+
#
|
146
|
+
# @return [String, nil]
|
147
|
+
#
|
148
|
+
def stderr
|
149
|
+
Array(options.err).each do |pipe|
|
150
|
+
next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
|
151
|
+
|
152
|
+
pipe.writers.each do |writer|
|
153
|
+
return writer.string if writer.respond_to?(:string)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
nil
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'errors'
|
4
|
+
|
5
|
+
module ProcessExecuter
|
6
|
+
# The `Runner` class executes subprocess commands and captures their status and output.
|
7
|
+
#
|
8
|
+
# It does the following:
|
9
|
+
# - Run commands (`call`) with options for capturing output, handling timeouts, and merging stdout/stderr.
|
10
|
+
# - Process command results, including logging and error handling.
|
11
|
+
# - Raise detailed exceptions for common command failures, such as timeouts or subprocess errors.
|
12
|
+
#
|
13
|
+
# This class is used internally by {ProcessExecuter.run}.
|
14
|
+
#
|
15
|
+
# @api public
|
16
|
+
#
|
17
|
+
class Runner
|
18
|
+
# Create a new RunCommand instance
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# runner = Runner.new()
|
22
|
+
# result = runner.call('echo', 'hello')
|
23
|
+
#
|
24
|
+
# @param logger [Logger] The logger to use. Defaults to a no-op logger if nil.
|
25
|
+
#
|
26
|
+
def initialize(logger = Logger.new(nil))
|
27
|
+
@logger = logger
|
28
|
+
end
|
29
|
+
|
30
|
+
# The logger to use
|
31
|
+
# @example
|
32
|
+
# runner.logger #=> #<Logger:0x00007f9b1b8b3d20>
|
33
|
+
# @return [Logger]
|
34
|
+
attr_reader :logger
|
35
|
+
|
36
|
+
# Run a command and return the status including stdout and stderr output
|
37
|
+
#
|
38
|
+
# @example
|
39
|
+
# runner = ProcessExecuter::Runner.new()
|
40
|
+
# result = runner.call('echo hello')
|
41
|
+
# result = ProcessExecuter.run('echo hello')
|
42
|
+
# result.success? # => true
|
43
|
+
# result.exitstatus # => 0
|
44
|
+
# result.stdout # => "hello\n"
|
45
|
+
# result.stderr # => ""
|
46
|
+
#
|
47
|
+
# @param command [Array<String>] The command to run
|
48
|
+
# @param out [#write, Array<#write>, nil] The object (or array of objects) to which stdout is written
|
49
|
+
# @param err [#write, Array<#write>, nil] The object (or array of objects) to which stderr is written
|
50
|
+
# @param merge [Boolean] Write both stdout and stderr into the buffer for stdout
|
51
|
+
# @param options_hash [Hash] Additional options to pass to Process.spawn
|
52
|
+
#
|
53
|
+
# See {ProcessExecuter.run} for a full list of options.
|
54
|
+
#
|
55
|
+
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
56
|
+
#
|
57
|
+
def call(*command, out: nil, err: nil, merge: false, **options_hash)
|
58
|
+
out ||= StringIO.new
|
59
|
+
err ||= (merge ? out : StringIO.new)
|
60
|
+
|
61
|
+
spawn(command, out:, err:, **options_hash).tap { |result| process_result(result) }
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Wrap the output buffers in pipes and then execute the command
|
67
|
+
#
|
68
|
+
# @param command [Array<String>] The command to execute
|
69
|
+
# @param out [#write, Array<#write>] The object (or array of objects) to which stdout is written
|
70
|
+
# @param err [#write, Array<#write>] The object (or array of objects) to which stderr is written
|
71
|
+
# @param options_hash [Hash] Additional options to pass to Process.spawn
|
72
|
+
#
|
73
|
+
# See {ProcessExecuter.run} for a full list of options.
|
74
|
+
#
|
75
|
+
# @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
|
76
|
+
# @raise [ProcessExecuter::TimeoutError] If the command times out
|
77
|
+
#
|
78
|
+
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
79
|
+
#
|
80
|
+
# @api private
|
81
|
+
#
|
82
|
+
def spawn(command, out:, err:, **options_hash)
|
83
|
+
out = [out] unless out.is_a?(Array)
|
84
|
+
err = [err] unless err.is_a?(Array)
|
85
|
+
out_pipe = ProcessExecuter::MonitoredPipe.new(*out)
|
86
|
+
err_pipe = ProcessExecuter::MonitoredPipe.new(*err)
|
87
|
+
ProcessExecuter.spawn_and_wait(*command, out: out_pipe, err: err_pipe, **options_hash)
|
88
|
+
ensure
|
89
|
+
out_pipe.close
|
90
|
+
err_pipe.close
|
91
|
+
raise_pipe_error(command, :stdout, out_pipe) if out_pipe.exception
|
92
|
+
raise_pipe_error(command, :stderr, err_pipe) if err_pipe.exception
|
93
|
+
end
|
94
|
+
|
95
|
+
# Process the result of the command and return a ProcessExecuter::Result
|
96
|
+
#
|
97
|
+
# Log the command and result, and raise an error if the command failed.
|
98
|
+
#
|
99
|
+
# @param result [ProcessExecuter::Result] The result of the command
|
100
|
+
#
|
101
|
+
# @return [Void]
|
102
|
+
#
|
103
|
+
# @raise [ProcessExecuter::FailedError] If the command failed
|
104
|
+
# @raise [ProcessExecuter::SignaledError] If the command was signaled
|
105
|
+
# @raise [ProcessExecuter::TimeoutError] If the command times out
|
106
|
+
# @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
|
107
|
+
#
|
108
|
+
# @api private
|
109
|
+
#
|
110
|
+
def process_result(result)
|
111
|
+
log_result(result)
|
112
|
+
|
113
|
+
return unless result.options.raise_errors
|
114
|
+
|
115
|
+
raise TimeoutError, result if result.timed_out?
|
116
|
+
raise SignaledError, result if result.signaled?
|
117
|
+
raise FailedError, result unless result.success?
|
118
|
+
end
|
119
|
+
|
120
|
+
# Log the result of running the command
|
121
|
+
# @param result [ProcessExecuter::Result] the result of the command including
|
122
|
+
# the command, status, stdout, and stderr
|
123
|
+
# @return [void]
|
124
|
+
# @api private
|
125
|
+
def log_result(result)
|
126
|
+
logger.info { "#{result.command} exited with status #{result}" }
|
127
|
+
logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
|
128
|
+
end
|
129
|
+
|
130
|
+
# Raise an error when there was exception while collecting the subprocess output
|
131
|
+
#
|
132
|
+
# @param command [Array<String>] The command that was executed
|
133
|
+
# @param pipe_name [Symbol] The name of the pipe that raised the exception
|
134
|
+
# @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
|
135
|
+
#
|
136
|
+
# @raise [ProcessExecuter::ProcessIOError]
|
137
|
+
#
|
138
|
+
# @return [void] This method always raises an error
|
139
|
+
#
|
140
|
+
# @api private
|
141
|
+
#
|
142
|
+
def raise_pipe_error(command, pipe_name, pipe)
|
143
|
+
error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
|
144
|
+
raise(error, cause: pipe.exception)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
data/lib/process_executer.rb
CHANGED
@@ -1,77 +1,331 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'process_executer/errors'
|
3
4
|
require 'process_executer/monitored_pipe'
|
4
5
|
require 'process_executer/options'
|
5
|
-
require 'process_executer/
|
6
|
+
require 'process_executer/result'
|
7
|
+
require 'process_executer/runner'
|
6
8
|
|
9
|
+
require 'logger'
|
7
10
|
require 'timeout'
|
8
11
|
|
9
|
-
#
|
12
|
+
# The `ProcessExecuter` module provides methods to execute subprocess commands
|
13
|
+
# with enhanced features such as output capture, timeout handling, and custom
|
14
|
+
# environment variables.
|
15
|
+
#
|
16
|
+
# Methods:
|
17
|
+
# * {run}: Executes a command and returns the result which includes the process
|
18
|
+
# status and output
|
19
|
+
# * {spawn_and_wait}: a thin wrapper around `Process.spawn` that blocks until the
|
20
|
+
# command finishes
|
21
|
+
#
|
22
|
+
# Features:
|
23
|
+
# * Supports executing commands via a shell or directly.
|
24
|
+
# * Captures stdout and stderr to buffers, files, or custom objects.
|
25
|
+
# * Optionally enforces timeouts and terminates long-running commands.
|
26
|
+
# * Provides detailed status information, including the command that was run, the
|
27
|
+
# options that were given, and success, failure, or timeout states.
|
10
28
|
#
|
11
29
|
# @api public
|
12
30
|
#
|
13
31
|
module ProcessExecuter
|
14
|
-
#
|
32
|
+
# Run a command in a subprocess, wait for it to finish, then return the result
|
15
33
|
#
|
16
|
-
# This is a
|
17
|
-
#
|
34
|
+
# This method is a thin wrapper around
|
35
|
+
# [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn)
|
36
|
+
# and blocks until the command terminates.
|
18
37
|
#
|
19
|
-
#
|
20
|
-
# the specified timeout.
|
38
|
+
# A timeout may be specified with the `:timeout_after` option. The command will be
|
39
|
+
# sent the SIGKILL signal if it does not terminate within the specified timeout.
|
21
40
|
#
|
22
41
|
# @example
|
23
|
-
#
|
24
|
-
#
|
25
|
-
#
|
26
|
-
#
|
42
|
+
# result = ProcessExecuter.spawn_and_wait('echo hello')
|
43
|
+
# result.exited? # => true
|
44
|
+
# result.success? # => true
|
45
|
+
# result.timed_out? # => false
|
27
46
|
#
|
28
47
|
# @example with a timeout
|
29
|
-
#
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
48
|
+
# result = ProcessExecuter.spawn_and_wait('sleep 10', timeout_after: 0.01)
|
49
|
+
# result.exited? # => false
|
50
|
+
# result.success? # => nil
|
51
|
+
# result.signaled? # => true
|
52
|
+
# result.termsig # => 9
|
53
|
+
# result.timed_out? # => true
|
35
54
|
#
|
36
55
|
# @example capturing stdout to a string
|
37
|
-
#
|
38
|
-
#
|
39
|
-
#
|
56
|
+
# stdout_buffer = StringIO.new
|
57
|
+
# stdout_pipe = ProcessExecuter::MonitoredPipe.new(stdout_buffer)
|
58
|
+
# result = ProcessExecuter.spawn_and_wait('echo hello', out: stdout_pipe)
|
59
|
+
# stdout_buffer.string # => "hello\n"
|
40
60
|
#
|
41
61
|
# @see https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-spawn Kernel.spawn
|
42
62
|
# documentation for valid command and options
|
43
63
|
#
|
44
|
-
# @see ProcessExecuter::Options#initialize
|
45
|
-
#
|
64
|
+
# @see ProcessExecuter::Options#initialize ProcessExecuter::Options#initialize for
|
65
|
+
# options that may be specified
|
46
66
|
#
|
47
|
-
# @param command [Array<String>]
|
48
|
-
# @param options_hash [Hash]
|
67
|
+
# @param command [Array<String>] The command to execute
|
68
|
+
# @param options_hash [Hash] The options to use when executing the command
|
49
69
|
#
|
50
|
-
# @return [
|
70
|
+
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
51
71
|
#
|
52
|
-
def self.
|
72
|
+
def self.spawn_and_wait(*command, **options_hash)
|
53
73
|
options = ProcessExecuter::Options.new(**options_hash)
|
54
74
|
pid = Process.spawn(*command, **options.spawn_options)
|
55
|
-
wait_for_process(pid, options)
|
75
|
+
wait_for_process(pid, command, options)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Execute the given command as a subprocess blocking until it finishes
|
79
|
+
#
|
80
|
+
# Works just like {ProcessExecuter.spawn}, but does the following in addition:
|
81
|
+
#
|
82
|
+
# 1. If nothing is specified for `out`, stdout is captured to a `StringIO` object
|
83
|
+
# which can be accessed via the Result object in `result.options.out`. The
|
84
|
+
# same applies to `err`.
|
85
|
+
#
|
86
|
+
# 2. If `merge` is set to `true`, stdout and stderr are captured to the same
|
87
|
+
# buffer.
|
88
|
+
#
|
89
|
+
# 3. `out` and `err` are automatically wrapped in a
|
90
|
+
# `ProcessExecuter::MonitoredPipe` object so that any object that implements
|
91
|
+
# `#write` (or an Array of such objects) can be given for `out` and `err`.
|
92
|
+
#
|
93
|
+
# 4. Raises one of the following errors unless `raise_errors` is explicitly set
|
94
|
+
# to `false`:
|
95
|
+
#
|
96
|
+
# * `ProcessExecuter::FailedError` if the command returns a non-zero
|
97
|
+
# exitstatus
|
98
|
+
# * `ProcessExecuter::SignaledError` if the command exits because of
|
99
|
+
# an unhandled signal
|
100
|
+
# * `ProcessExecuter::TimeoutError` if the command times out
|
101
|
+
#
|
102
|
+
# If `raise_errors` is false, the returned Result object will contain the error.
|
103
|
+
#
|
104
|
+
# 5. Raises a `ProcessExecuter::ProcessIOError` if an exception is raised
|
105
|
+
# while collecting subprocess output. This can not be turned off.
|
106
|
+
#
|
107
|
+
# 6. If a `logger` is provided, it will be used to log:
|
108
|
+
#
|
109
|
+
# * The command that was executed and its status to `info` level
|
110
|
+
# * The stdout and stderr output to `debug` level
|
111
|
+
#
|
112
|
+
# By default, Logger.new(nil) is used for the logger.
|
113
|
+
#
|
114
|
+
# This method takes two forms:
|
115
|
+
#
|
116
|
+
# 1. The command is executed via a shell when the command is given as a single
|
117
|
+
# string:
|
118
|
+
#
|
119
|
+
# `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Result}
|
120
|
+
#
|
121
|
+
# 2. The command is executed directly (bypassing the shell) when the command and it
|
122
|
+
# arguments are given as an array of strings:
|
123
|
+
#
|
124
|
+
# `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Result}
|
125
|
+
#
|
126
|
+
# Optional argument `env` is a hash that affects ENV for the new process; see
|
127
|
+
# [Execution
|
128
|
+
# Environment](https://docs.ruby-lang.org/en/3.3/Process.html#module-Process-label-Execution+Environment).
|
129
|
+
#
|
130
|
+
# Argument `options` is a hash of options for the new process. See the options listed below.
|
131
|
+
#
|
132
|
+
# @example Run a command given as a single string (uses shell)
|
133
|
+
# # The command must be properly shell escaped when passed as a single string.
|
134
|
+
# command = 'echo "stdout: `pwd`" && echo "stderr: $HOME" 1>&2'
|
135
|
+
# result = ProcessExecuter.run(command)
|
136
|
+
# result.success? #=> true
|
137
|
+
# result.stdout #=> "stdout: /Users/james/projects/main-branch/process_executer\n"
|
138
|
+
# result.stderr #=> "stderr: /Users/james\n"
|
139
|
+
#
|
140
|
+
# @example Run a command given as an array of strings (does not use shell)
|
141
|
+
# # The command and its args must be provided as separate strings in the array.
|
142
|
+
# # Shell expansions and redirections are not supported.
|
143
|
+
# command = ['git', 'clone', 'https://github.com/main-branch/process_executer']
|
144
|
+
# result = ProcessExecuter.run(*command)
|
145
|
+
# result.success? #=> true
|
146
|
+
# result.stdout #=> ""
|
147
|
+
# result.stderr #=> "Cloning into 'process_executer'...\n"
|
148
|
+
#
|
149
|
+
# @example Run a command with a timeout
|
150
|
+
# command = ['sleep', '1']
|
151
|
+
# result = ProcessExecuter.run(*command, timeout_after: 0.01)
|
152
|
+
# #=> raises ProcessExecuter::TimeoutError which contains the command result
|
153
|
+
#
|
154
|
+
# @example Run a command which fails
|
155
|
+
# command = ['exit 1']
|
156
|
+
# result = ProcessExecuter.run(*command)
|
157
|
+
# #=> raises ProcessExecuter::FailedError which contains the command result
|
158
|
+
#
|
159
|
+
# @example Run a command which exits due to an unhandled signal
|
160
|
+
# command = ['kill -9 $$']
|
161
|
+
# result = ProcessExecuter.run(*command)
|
162
|
+
# #=> raises ProcessExecuter::SignaledError which contains the command result
|
163
|
+
#
|
164
|
+
# @example Do not raise an error when the command fails
|
165
|
+
# command = ['echo "Some error" 1>&2 && exit 1']
|
166
|
+
# result = ProcessExecuter.run(*command, raise_errors: false)
|
167
|
+
# result.success? #=> false
|
168
|
+
# result.exitstatus #=> 1
|
169
|
+
# result.stdout #=> ""
|
170
|
+
# result.stderr #=> "Some error\n"
|
171
|
+
#
|
172
|
+
# @example Set environment variables
|
173
|
+
# env = { 'FOO' => 'foo', 'BAR' => 'bar' }
|
174
|
+
# command = 'echo "$FOO$BAR"'
|
175
|
+
# result = ProcessExecuter.run(env, *command)
|
176
|
+
# result.stdout #=> "foobar\n"
|
177
|
+
#
|
178
|
+
# @example Set environment variables when using a command array
|
179
|
+
# env = { 'FOO' => 'foo', 'BAR' => 'bar' }
|
180
|
+
# command = ['ruby', '-e', 'puts ENV["FOO"] + ENV["BAR"]']
|
181
|
+
# result = ProcessExecuter.run(env, *command)
|
182
|
+
# result.stdout #=> "foobar\n"
|
183
|
+
#
|
184
|
+
# @example Unset environment variables
|
185
|
+
# env = { 'FOO' => nil } # setting to nil unsets the variable in the environment
|
186
|
+
# command = ['echo "FOO: $FOO"']
|
187
|
+
# result = ProcessExecuter.run(env, *command)
|
188
|
+
# result.stdout #=> "FOO: \n"
|
189
|
+
#
|
190
|
+
# @example Reset existing environment variables and add new ones
|
191
|
+
# env = { 'PATH' => '/bin' }
|
192
|
+
# result = ProcessExecuter.run(env, 'echo "Home: $HOME" && echo "Path: $PATH"', unsetenv_others: true)
|
193
|
+
# result.stdout #=> "Home: \n/Path: /bin\n"
|
194
|
+
#
|
195
|
+
# @example Run command in a different directory
|
196
|
+
# command = ['pwd']
|
197
|
+
# result = ProcessExecuter.run(*command, chdir: '/tmp')
|
198
|
+
# result.stdout #=> "/tmp\n"
|
199
|
+
#
|
200
|
+
# @example Capture stdout and stderr into a single buffer
|
201
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
202
|
+
# result = ProcessExecuter.run(*command, merge: true)
|
203
|
+
# result.stdout #=> "stdout\nstderr\n"
|
204
|
+
# result.stderr #=> "stdout\nstderr\n"
|
205
|
+
# result.stdout.object_id == result.stderr.object_id #=> true
|
206
|
+
#
|
207
|
+
# @example Capture to an explicit buffer
|
208
|
+
# out = StringIO.new
|
209
|
+
# err = StringIO.new
|
210
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
211
|
+
# result = ProcessExecuter.run(*command, out: out, err: err)
|
212
|
+
# out.string #=> "stdout\n"
|
213
|
+
# err.string #=> "stderr\n"
|
214
|
+
#
|
215
|
+
# @example Capture to a file
|
216
|
+
# # Same technique can be used for stderr
|
217
|
+
# out = File.open('stdout.txt', 'w')
|
218
|
+
# err = StringIO.new
|
219
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
220
|
+
# result = ProcessExecuter.run(*command, out: out, err: err)
|
221
|
+
# out.close
|
222
|
+
# File.read('stdout.txt') #=> "stdout\n"
|
223
|
+
# # stderr is still captured to a StringIO buffer internally
|
224
|
+
# result.stderr #=> "stderr\n"
|
225
|
+
#
|
226
|
+
# @example Capture to multiple writers (e.g. files, buffers, STDOUT, etc.)
|
227
|
+
# # Same technique can be used for stderr
|
228
|
+
# out_buffer = StringIO.new
|
229
|
+
# out_file = File.open('stdout.txt', 'w')
|
230
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
231
|
+
# result = ProcessExecuter.run(*command, out: [out_buffer, out_file])
|
232
|
+
# # You must manage closing resources you create yourself
|
233
|
+
# out_file.close
|
234
|
+
# out_buffer.string #=> "stdout\n"
|
235
|
+
# File.read('stdout.txt') #=> "stdout\n"
|
236
|
+
# # Since one of the out writers has a #string method, Result#stdout will
|
237
|
+
# # return the string from that writer
|
238
|
+
# result.stdout #=> "stdout\n"
|
239
|
+
#
|
240
|
+
# @param command [Array<String>] The command to run
|
241
|
+
#
|
242
|
+
# If the first element of command is a Hash, it is added to the ENV of
|
243
|
+
# the new process. See [Execution Environment](https://ruby-doc.org/3.3.6/Process.html#module-Process-label-Execution+Environment)
|
244
|
+
# for more details. The env hash is then removed from the command array.
|
245
|
+
#
|
246
|
+
# If the first and only (remaining) command element is a string, it is passed to
|
247
|
+
# a subshell if it begins with a shell reserved word, contains special built-ins,
|
248
|
+
# or includes shell metacharacters.
|
249
|
+
#
|
250
|
+
# Care must be taken to properly escape shell metacharacters in the command string.
|
251
|
+
#
|
252
|
+
# Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
|
253
|
+
# and redirections are not supported.
|
254
|
+
#
|
255
|
+
# @param logger [Logger] The logger to use
|
256
|
+
# @param options_hash [Hash] Additional options
|
257
|
+
# @option options_hash [Numeric] :timeout_after The maximum seconds to wait for the
|
258
|
+
# command to complete
|
259
|
+
#
|
260
|
+
# If zero or nil, the command will not time out. If the command
|
261
|
+
# times out, it is killed via a SIGKILL signal. A {ProcessExecuter::TimeoutError}
|
262
|
+
# will be raised if the `:raise_errors` option is true.
|
263
|
+
#
|
264
|
+
# If the command does not exit when receiving the SIGKILL signal, this method may hang indefinitely.
|
265
|
+
#
|
266
|
+
# @option options_hash [#write] :out (nil) The object to write stdout to
|
267
|
+
# @option options_hash [#write] :err (nil) The object to write stderr to
|
268
|
+
# @option options_hash [Boolean] :merge (false) If true, stdout and stderr are written to the same capture buffer
|
269
|
+
# @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails
|
270
|
+
# @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before
|
271
|
+
# applying the new ones
|
272
|
+
# @option options_hash [true, Integer, nil] :pgroup (nil) true or 0: new process group; non-zero: join
|
273
|
+
# the group, nil: existing group
|
274
|
+
# @option options_hash [Boolean] :new_pgroup (nil) Create a new process group (Windows only)
|
275
|
+
# @option options_hash [Integer] :rlimit_resource_name (nil) Set resource limits (see Process.setrlimit)
|
276
|
+
# @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
|
277
|
+
# @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
|
278
|
+
# @option options_hash [String] :chdir (nil) The directory to run the command in
|
279
|
+
#
|
280
|
+
# @raise [ProcessExecuter::FailedError] if the command returned a non-zero exit status
|
281
|
+
# @raise [ProcessExecuter::SignaledError] if the command exited because of an unhandled signal
|
282
|
+
# @raise [ProcessExecuter::TimeoutError] if the command timed out
|
283
|
+
# @raise [ProcessExecuter::ProcessIOError] if an exception was raised while collecting subprocess output
|
284
|
+
#
|
285
|
+
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
286
|
+
#
|
287
|
+
def self.run(*command, logger: Logger.new(nil), **options_hash)
|
288
|
+
ProcessExecuter::Runner.new(logger).call(*command, **options_hash)
|
56
289
|
end
|
57
290
|
|
58
291
|
# Wait for process to terminate
|
59
292
|
#
|
60
|
-
# If a
|
293
|
+
# If a `:timeout_after` is specified in options, terminate the process after the
|
294
|
+
# specified number of seconds.
|
61
295
|
#
|
62
|
-
# @param pid [Integer] the process
|
296
|
+
# @param pid [Integer] the process ID
|
63
297
|
# @param options [ProcessExecuter::Options] the options used
|
64
298
|
#
|
65
|
-
# @return [ProcessExecuter::
|
299
|
+
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
66
300
|
#
|
67
301
|
# @api private
|
68
302
|
#
|
69
|
-
private_class_method def self.wait_for_process(pid, options)
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
303
|
+
private_class_method def self.wait_for_process(pid, command, options)
|
304
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
305
|
+
process_status, timed_out = wait_for_process_raw(pid, options.timeout_after)
|
306
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
307
|
+
ProcessExecuter::Result.new(process_status, command:, options:, timed_out:, elapsed_time:)
|
308
|
+
end
|
309
|
+
|
310
|
+
# Wait for a process to terminate returning the status and timed out flag
|
311
|
+
#
|
312
|
+
# @param pid [Integer] the process ID
|
313
|
+
# @param timeout_after [Numeric, nil] the number of seconds to wait for the process to terminate
|
314
|
+
# @return [Array<Process::Status, Boolean>] an array containing the process status and a boolean
|
315
|
+
# indicating whether the process timed out
|
316
|
+
# @api private
|
317
|
+
private_class_method def self.wait_for_process_raw(pid, timeout_after)
|
318
|
+
timed_out = false
|
319
|
+
|
320
|
+
process_status =
|
321
|
+
begin
|
322
|
+
Timeout.timeout(timeout_after) { Process.wait2(pid).last }
|
323
|
+
rescue Timeout::Error
|
324
|
+
Process.kill('KILL', pid)
|
325
|
+
timed_out = true
|
326
|
+
Process.wait2(pid).last
|
327
|
+
end
|
328
|
+
|
329
|
+
[process_status, timed_out]
|
76
330
|
end
|
77
331
|
end
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: process_executer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James Couball
|
8
|
-
autorequire:
|
9
8
|
bindir: exe
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 2025-03-03 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: bundler-audit
|
@@ -210,7 +209,6 @@ files:
|
|
210
209
|
- ".markdownlint.yml"
|
211
210
|
- ".rspec"
|
212
211
|
- ".rubocop.yml"
|
213
|
-
- ".tool-versions"
|
214
212
|
- ".yardopts"
|
215
213
|
- CHANGELOG.md
|
216
214
|
- Gemfile
|
@@ -218,9 +216,11 @@ files:
|
|
218
216
|
- README.md
|
219
217
|
- Rakefile
|
220
218
|
- lib/process_executer.rb
|
219
|
+
- lib/process_executer/errors.rb
|
221
220
|
- lib/process_executer/monitored_pipe.rb
|
222
221
|
- lib/process_executer/options.rb
|
223
|
-
- lib/process_executer/
|
222
|
+
- lib/process_executer/result.rb
|
223
|
+
- lib/process_executer/runner.rb
|
224
224
|
- lib/process_executer/version.rb
|
225
225
|
- package.json
|
226
226
|
- process_executer.gemspec
|
@@ -231,10 +231,9 @@ metadata:
|
|
231
231
|
allowed_push_host: https://rubygems.org
|
232
232
|
homepage_uri: https://github.com/main-branch/process_executer
|
233
233
|
source_code_uri: https://github.com/main-branch/process_executer
|
234
|
-
documentation_uri: https://rubydoc.info/gems/process_executer/
|
235
|
-
changelog_uri: https://rubydoc.info/gems/process_executer/
|
234
|
+
documentation_uri: https://rubydoc.info/gems/process_executer/2.0.0
|
235
|
+
changelog_uri: https://rubydoc.info/gems/process_executer/2.0.0/file/CHANGELOG.md
|
236
236
|
rubygems_mfa_required: 'true'
|
237
|
-
post_install_message:
|
238
237
|
rdoc_options: []
|
239
238
|
require_paths:
|
240
239
|
- lib
|
@@ -251,8 +250,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
251
250
|
requirements:
|
252
251
|
- 'Platform: Mac, Linux, or Windows'
|
253
252
|
- 'Ruby: MRI 3.1 or later, TruffleRuby 24 or later, or JRuby 9.4 or later'
|
254
|
-
rubygems_version: 3.
|
255
|
-
signing_key:
|
253
|
+
rubygems_version: 3.6.2
|
256
254
|
specification_version: 4
|
257
255
|
summary: An API for executing commands in a subprocess
|
258
256
|
test_files: []
|
data/.tool-versions
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
ruby 3.3.5
|
@@ -1,45 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'delegate'
|
4
|
-
require 'forwardable'
|
5
|
-
|
6
|
-
module ProcessExecuter
|
7
|
-
# A simple delegator for Process::Status that adds a `timeout?` attribute
|
8
|
-
#
|
9
|
-
# @api public
|
10
|
-
#
|
11
|
-
class Status < SimpleDelegator
|
12
|
-
extend Forwardable
|
13
|
-
|
14
|
-
# Create a new Status object from a Process::Status and timeout flag
|
15
|
-
#
|
16
|
-
# @param status [Process::Status] the status to delegate to
|
17
|
-
# @param timeout [Boolean] true if the process timed out
|
18
|
-
#
|
19
|
-
# @example
|
20
|
-
# status = Process.wait2(pid).last
|
21
|
-
# timeout = false
|
22
|
-
# ProcessExecuter::Status.new(status, timeout)
|
23
|
-
#
|
24
|
-
# @api public
|
25
|
-
#
|
26
|
-
def initialize(status, timeout)
|
27
|
-
super(status)
|
28
|
-
@timeout = timeout
|
29
|
-
end
|
30
|
-
|
31
|
-
# @!attribute [r] timeout?
|
32
|
-
#
|
33
|
-
# True if the process timed out and was sent the SIGKILL signal
|
34
|
-
#
|
35
|
-
# @example
|
36
|
-
# status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
|
37
|
-
# status.timeout? # => true
|
38
|
-
#
|
39
|
-
# @return [Boolean]
|
40
|
-
#
|
41
|
-
# @api public
|
42
|
-
#
|
43
|
-
def timeout? = @timeout
|
44
|
-
end
|
45
|
-
end
|