process_executer 1.3.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/CHANGELOG.md +8 -0
- data/README.md +23 -23
- data/lib/process_executer/errors.rb +134 -0
- 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 +135 -83
- metadata +8 -14
- data/.tool-versions +0 -1
- data/lib/process_executer/command/errors.rb +0 -170
- data/lib/process_executer/command/result.rb +0 -77
- data/lib/process_executer/command/runner.rb +0 -167
- data/lib/process_executer/command.rb +0 -12
- data/lib/process_executer/status.rb +0 -70
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/CHANGELOG.md
CHANGED
@@ -5,6 +5,14 @@ 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
|
+
|
8
16
|
## v1.3.0 (2025-02-26)
|
9
17
|
|
10
18
|
[Full Changelog](https://github.com/main-branch/process_executer/compare/v1.2.0..v1.3.0)
|
data/README.md
CHANGED
@@ -11,16 +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
|
-
|
16
|
-
|
14
|
+
* [ProcessExecuter.run](#processexecuterrun)
|
15
|
+
* [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
|
16
|
+
* [ProcessExecuter.spawn\_and\_wait](#processexecuterspawn_and_wait)
|
17
17
|
* [Installation](#installation)
|
18
18
|
* [Contributing](#contributing)
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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)
|
24
24
|
* [License](#license)
|
25
25
|
|
26
26
|
## Usage
|
@@ -40,17 +40,17 @@ Supports the same features as
|
|
40
40
|
[Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn).
|
41
41
|
In addition, it (1) blocks until the command has exited, (2) captures stdout and
|
42
42
|
stderr to a buffer or file, and (3) can optionally kill the command if it exceeds
|
43
|
-
|
43
|
+
a given timeout duration.
|
44
44
|
|
45
45
|
This command takes two forms:
|
46
46
|
|
47
47
|
1. When passing a single string the command is passed to a shell:
|
48
48
|
|
49
|
-
`ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::
|
49
|
+
`ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Result}
|
50
50
|
|
51
51
|
2. When passing an array of strings the command is run directly (bypassing the shell):
|
52
52
|
|
53
|
-
`ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::
|
53
|
+
`ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Result}
|
54
54
|
|
55
55
|
Argument env, if given, is a hash that affects ENV for the new process; see
|
56
56
|
[Execution
|
@@ -99,27 +99,27 @@ output_buffer.string #=> "Hello World\n"
|
|
99
99
|
File.read('process.out') #=> "Hello World\n"
|
100
100
|
```
|
101
101
|
|
102
|
-
Since the data is streamed, any object that implements `#write` can be used. For
|
103
|
-
you can use it to parse process output as a stream which might be useful for
|
104
|
-
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.
|
105
105
|
|
106
|
-
### ProcessExecuter.
|
106
|
+
### ProcessExecuter.spawn_and_wait
|
107
107
|
|
108
108
|
`ProcessExecuter.spawn` has the same interface as `Process.spawn` but has two
|
109
109
|
important behaviorial differences:
|
110
110
|
|
111
111
|
1. It blocks until the subprocess finishes
|
112
|
-
2. A timeout can be specified using the `:
|
112
|
+
2. A timeout can be specified using the `:timeout_after` option
|
113
113
|
|
114
|
-
If the command does not terminate before the
|
115
|
-
sending it the SIGKILL signal. The
|
116
|
-
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:
|
117
117
|
|
118
118
|
```ruby
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
123
123
|
```
|
124
124
|
|
125
125
|
## Installation
|
@@ -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
|
@@ -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
|