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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c875872382e447e7af6dfec78af45242baa13c7b832d5c25f3e146bbc2b9f1d
4
- data.tar.gz: a68da52f9b1dfae542386a0d61abc85cdc0d86ee0471c4893692061db661f4e5
3
+ metadata.gz: 658921c991aa6654711a48a4638e0de6d998f9b5bcdabf8314cad15bb7294a0f
4
+ data.tar.gz: 7aee46a6fe1459cbd4e0bc9a67895a3c9946741b192e3766937b90ddad416c03
5
5
  SHA512:
6
- metadata.gz: a0f6b19a6570dab6843b0d76af1839496ae7ff0651a3f485d6465414b3eb6f0d4a78236bc9ae80ad1b4d7e2e43d8c093ef2733e047eaadb27f3c2e7f174e5f82
7
- data.tar.gz: 65da6657d7b821f2b0641bcad6278ded70fd78c06497dfa93fa67ab52c6bb0b57443fa6aa92b5cc160b68ff3b4b68165208b2ff77eea0f4b50542e1243a62c8e
6
+ metadata.gz: c25a01c7d819932a0495b18fc332744c860df5d25c8234df15071ac3ee66a9f6bc6b6e0101e2b02bc0fb691af3a7ac5bf9a77a98565b44964abcbc3d63f4451e
7
+ data.tar.gz: 915c369b55e999c726c22b2e3ef0b377ac0253afd115615d4cd2cb8ae17244c5f042bde4018bf31cb76ec30baf924761cb26e045577959b29e3f7aedcceddb65
data/.yardopts CHANGED
@@ -3,6 +3,4 @@
3
3
  --markup-provider=redcarpet
4
4
  --markup markdown
5
5
  - CHANGELOG.md
6
- - CONTRIBUTING.md
7
- - RELEASING.md
8
6
  - LICENSE.txt
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
  [![Slack](https://img.shields.io/badge/slack-main--branch/process__executer-yellow.svg?logo=slack)](https://main-branch.slack.com/archives/C07NG2BPG8Y)
12
12
 
13
13
  * [Usage](#usage)
14
- * [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
15
- * [ProcessExecuter.spawn](#processexecuterspawn)
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
- * [Reporting Issues](#reporting-issues)
19
- * [Developing](#developing)
20
- * [Commit message guidelines](#commit-message-guidelines)
21
- * [Pull request guidelines](#pull-request-guidelines)
22
- * [Releasing](#releasing)
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 insance,
72
- you can use it to parse process output as a stream which might be useful for long XML
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.spawn
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 `:timeout` option
112
+ 2. A timeout can be specified using the `:timeout_after` option
82
113
 
83
- If the command does not terminate before the timeout, the process is killed by
84
- sending it the SIGKILL signal. The returned status object's `timeout?` attribute will
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
- status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
89
- status.signaled? #=> true
90
- status.termsig #=> 9
91
- status.timeout? #=> true
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 yard:audit yard:coverage bundle:audit build]
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
- # YARD
53
+ # yard:build
54
54
 
55
55
  require 'yard'
56
- YARD::Rake::YardocTask.new do |t|
57
- t.files = %w[lib/**/*.rb examples/**/*]
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
- # Yardstick
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
- # Yardstick coverage
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
- # SimpleCov under JRuby reports the begin statement as not covered, but it is
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
- # * `:timeout`:
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
- timeout
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
- timeout: nil
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, timeout: 10)
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] :timeout
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, timeout: 10)
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 timeout is not a real non-negative number
134
+ # Raise an error if timeout_after is not a non-negative real number
134
135
  # @return [void]
135
- # @raise [ArgumentError] if timeout is not a real non-negative number
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[:timeout].nil?
139
- return if @options[:timeout].is_a?(Numeric) && @options[:timeout].real? && !@options[:timeout].negative?
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, invalid_timeout_message
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 timeout
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 invalid_timeout_message
148
- "timeout must be nil or a real non-negative number but was #{options[:timeout].pretty_inspect}"
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
- VERSION = '1.2.0'
5
+ VERSION = '2.0.0'
6
6
  end
@@ -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/status'
6
+ require 'process_executer/result'
7
+ require 'process_executer/runner'
6
8
 
9
+ require 'logger'
7
10
  require 'timeout'
8
11
 
9
- # Execute a command in a subprocess and optionally capture its output
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
- # Execute the specified command as a subprocess and return the exit status
32
+ # Run a command in a subprocess, wait for it to finish, then return the result
15
33
  #
16
- # This is a convenience method that calls Process.spawn and blocks until the
17
- # command has terminated.
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
- # The command will be send the SIGKILL signal if it does not terminate within
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
- # status = ProcessExecuter.spawn('echo hello')
24
- # status.exited? # => true
25
- # status.success? # => true
26
- # status.timeout? # => false
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
- # status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
30
- # status.exited? # => false
31
- # status.success? # => nil
32
- # status.signaled? # => true
33
- # status.termsig # => 9
34
- # status.timeout? # => true
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
- # stdout = StringIO.new
38
- # status = ProcessExecuter.spawn('echo hello', out: stdout)
39
- # stdout.string # => "hello"
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 See ProcessExecuter::Options#initialize
45
- # for options that may be specified
64
+ # @see ProcessExecuter::Options#initialize ProcessExecuter::Options#initialize for
65
+ # options that may be specified
46
66
  #
47
- # @param command [Array<String>] the command to execute
48
- # @param options_hash [Hash] the options to use when exectuting the command
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 [Process::Status] the exit status of the proceess
70
+ # @return [ProcessExecuter::Result] The result of the completed subprocess
51
71
  #
52
- def self.spawn(*command, **options_hash)
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 timeout is speecified in options, kill the process after options.timeout seconds.
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 id
296
+ # @param pid [Integer] the process ID
63
297
  # @param options [ProcessExecuter::Options] the options used
64
298
  #
65
- # @return [ProcessExecuter::Status] the status of the process
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
- Timeout.timeout(options.timeout) do
71
- ProcessExecuter::Status.new(Process.wait2(pid).last, false)
72
- end
73
- rescue Timeout::Error
74
- Process.kill('KILL', pid)
75
- ProcessExecuter::Status.new(Process.wait2(pid).last, true)
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: 1.2.0
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: 2024-10-11 00:00:00.000000000 Z
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/status.rb
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/1.2.0
235
- changelog_uri: https://rubydoc.info/gems/process_executer/1.2.0/file/CHANGELOG.md
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.5.16
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