process_executer 4.0.4 → 4.1.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: 326f0746501479260d59d78579f9ff9e049a516a7b76e177e7ade04b90f0eece
4
- data.tar.gz: 7352423248b6d8d7b1785e450ff0aae1c836fa1dfcc2a4e6b791d29d8a3d386c
3
+ metadata.gz: 63acfcf3541790f98d140feac5a9badc901f8b4e475b3c97d125f6b549556bfc
4
+ data.tar.gz: 7c42a5cfa8b66fea530be0821443a974c1fabc1002fc2b3f2f35e533812c9c6e
5
5
  SHA512:
6
- metadata.gz: 2147f37dc71f48a5c8a525103bc517d4be9bf4fde29f1a7d4d12952cf42976c96567256c5eea8ffe101d409829f63011093828551bc30fc779627978df7219c7
7
- data.tar.gz: 175fe9b38d29b3b6c7441293ca837344231019f22d0cb815013cd766f606487873ebb062134e00a493f3a5c41a7f1f5b6c2d3e152ea51ab800f12536aa695d50
6
+ metadata.gz: b8d54aa2b9c5e1c153a4c92dbaeec6d96c3b80f31f6517ad2026519d070eff6d6c5b6fdc447cfbff481a6c15599c06ace734068f784e1f1ca8ba78831e84e0d7
7
+ data.tar.gz: fef70d1e6342139152e498dd72572f591f5f330f8617e7ad0603898638c08db8e2ec09aeb302e486949432d80a7635611654959869314946df13c1bf87ed4dd0
data/.editorconfig ADDED
@@ -0,0 +1,10 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ charset = utf-8
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+
9
+ [*.md]
10
+ trim_trailing_whitespace = false
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "4.0.4"
2
+ ".": "4.1.0"
3
3
  }
data/.rubocop.yml CHANGED
@@ -5,3 +5,16 @@ AllCops:
5
5
  # Pin this project to Ruby 3.1 in case the shared config above is upgraded to 3.2
6
6
  # or later.
7
7
  TargetRubyVersion: 3.1
8
+
9
+ # The default `native` style expects CRLF on Windows, but this project's files
10
+ # are LF everywhere. Pin the style so RuboCop gives the same result on Windows
11
+ # as it does on Linux and macOS.
12
+ Layout/EndOfLine:
13
+ EnforcedStyle: lf
14
+
15
+ # MonitoredPipe grew past the default limit when the close deadline handling
16
+ # was added for issue #164. Its pipe lifecycle logic (open, monitor, drain,
17
+ # close) is cohesive and would not be clearer split across classes.
18
+ Metrics/ClassLength:
19
+ Exclude:
20
+ - "lib/process_executer/monitored_pipe.rb"
@@ -0,0 +1,3 @@
1
+ {
2
+ "recommendations": ["editorconfig.editorconfig"]
3
+ }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ 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
+ ## [4.1.0](https://github.com/main-branch/process_executer/compare/v4.0.4...v4.1.0) (2026-08-30)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * Make timeout_after bound execution even when descendants hold stdout/stderr open ([a1298d3](https://github.com/main-branch/process_executer/commit/a1298d3f98fd8e57f58f50a0566dd3be78079189))
14
+ * **monitored_pipe:** Close acquired resources when #initialize fails partway ([8f10a86](https://github.com/main-branch/process_executer/commit/8f10a86b42c4d72f0f34004071cfeb91116d6bd4)), closes [#178](https://github.com/main-branch/process_executer/issues/178)
15
+ * **monitored_pipe:** Do not hold the mutex while writing to the pipe ([91d7fe8](https://github.com/main-branch/process_executer/commit/91d7fe88b9950d24f8c28ccbea400be0063ec99a)), closes [#165](https://github.com/main-branch/process_executer/issues/165)
16
+ * **monitored_pipe:** Handle a destination raising a non-StandardError ([a7de23b](https://github.com/main-branch/process_executer/commit/a7de23b5699ef4d32518e988ac76dd6a957277e3)), closes [#170](https://github.com/main-branch/process_executer/issues/170)
17
+ * Replace the monitor thread's 1ms poll with an event-driven wakeup pipe ([44068a3](https://github.com/main-branch/process_executer/commit/44068a35f4f87de9b7b46ce1deffda636f7c1138))
18
+ * Return a timed out result when the timeout races Process.wait2 reaping the child ([84a6a11](https://github.com/main-branch/process_executer/commit/84a6a11df7d736f26d98700e90a76f5d65e2d35c)), closes [#171](https://github.com/main-branch/process_executer/issues/171)
19
+ * **run_with_capture:** Capture combined [:out, :err] redirection into stdout ([17199b8](https://github.com/main-branch/process_executer/commit/17199b8ea332278680c46accce072763f0d67a4a)), closes [#167](https://github.com/main-branch/process_executer/issues/167)
20
+ * **run:** Close created pipes and keep the original error when pipe creation fails ([909eaa1](https://github.com/main-branch/process_executer/commit/909eaa177fa53ee4cd4e773595a8658ea94ca6a4)), closes [#166](https://github.com/main-branch/process_executer/issues/166)
21
+ * **run:** Stop mutating caller options and closing caller-owned pipes ([96d922b](https://github.com/main-branch/process_executer/commit/96d922b4bff5586189b30d9a1f83b0d47159710f)), closes [#169](https://github.com/main-branch/process_executer/issues/169)
22
+ * Validate options merged via Options::Base#merge! ([#173](https://github.com/main-branch/process_executer/issues/173)) ([da0a956](https://github.com/main-branch/process_executer/commit/da0a956bcb085378b1315dafb87351174500c966))
23
+
24
+
25
+ ### Other Changes
26
+
27
+ * Add failing tests for merge! bypassing option validation ([#173](https://github.com/main-branch/process_executer/issues/173)) ([325f16e](https://github.com/main-branch/process_executer/commit/325f16e589821571f3250e86a82f51f0e6b70f9a))
28
+ * Add failing tests for the monitor thread 1ms polling loop ([#172](https://github.com/main-branch/process_executer/issues/172)) ([e202909](https://github.com/main-branch/process_executer/commit/e202909f99074a8ceb331f13290c224c35904e7a))
29
+ * Add failing tests for the timeout vs Process.wait2 reap race ([#171](https://github.com/main-branch/process_executer/issues/171)) ([647739a](https://github.com/main-branch/process_executer/commit/647739ad7233059aafb54dad70eef14998892c5d))
30
+ * Add failing tests for timeout_after not bounding execution ([#164](https://github.com/main-branch/process_executer/issues/164)) ([8e5115c](https://github.com/main-branch/process_executer/commit/8e5115c9236347e77ba6e6d0631f5f1bfe826b26))
31
+ * **coverage:** Only enforce code coverage on MRI builds not on Windows ([160815e](https://github.com/main-branch/process_executer/commit/160815ecb091d1f155406f9a1b37f73e13690627))
32
+ * Enforce LF line endings on all platforms ([e94bfea](https://github.com/main-branch/process_executer/commit/e94bfeaf4bd93506090277b4020859cd23dfc625))
33
+ * Explain stdin redirection via the in: option ([f7bded7](https://github.com/main-branch/process_executer/commit/f7bded743a204a99c7f6e272b0330d4ba1e4a037)), closes [#159](https://github.com/main-branch/process_executer/issues/159)
34
+ * **monitored_pipe:** Accept the documented IOError and confine failures ([002274d](https://github.com/main-branch/process_executer/commit/002274d421afc84c7c441c6f56dbbc32506e1de2)), closes [#182](https://github.com/main-branch/process_executer/issues/182)
35
+ * **monitored_pipe:** Add a failing test for the #write deadlock ([9826558](https://github.com/main-branch/process_executer/commit/9826558035b03fe7becdc729053519141ba8d00d))
36
+ * **monitored_pipe:** Add failing tests for a destination raising a non-StandardError ([0177329](https://github.com/main-branch/process_executer/commit/0177329fb47f3c185d9ea768f9f80028c54fb7ab))
37
+ * **monitored_pipe:** Add failing tests for resource leaks when #initialize fails ([b2e3151](https://github.com/main-branch/process_executer/commit/b2e31519ea7a38f774b90f6e9e1f17fa0b31baa3)), closes [#178](https://github.com/main-branch/process_executer/issues/178)
38
+ * **monitored_pipe:** Make the writer-exception race deterministic ([c72f1e4](https://github.com/main-branch/process_executer/commit/c72f1e400ef3d78e4b092ecde0d0704cf79ebc36)), closes [#182](https://github.com/main-branch/process_executer/issues/182)
39
+ * **monitored_pipe:** Stub the monitor loop on the class, not the live instance ([799177f](https://github.com/main-branch/process_executer/commit/799177f6046d661013ce6b68b19c6b220294d00f))
40
+ * **process-spawn:** Check exit status and blocking, and add a subspawn backend ([99021d2](https://github.com/main-branch/process_executer/commit/99021d2144a21210f793b7ae94d36875eef504e6))
41
+ * **process-spawn:** Run the spawn tests against the subspawn backend ([573086f](https://github.com/main-branch/process_executer/commit/573086f194e790f25d916de32bb5e94437a5ae86))
42
+ * **rake:** Skip RuboCop in the default task on TruffleRuby ([3d2a118](https://github.com/main-branch/process_executer/commit/3d2a1186d65e76f170f5428d611822d95d8fdc2d))
43
+ * Release the hardening changes as v4.1.0 instead of v4.0.5 ([70b6327](https://github.com/main-branch/process_executer/commit/70b63278f334bd8b714c2fc28ded1520921add09))
44
+ * **run_with_capture:** Add failing tests for combined [:out, :err] redirection ([61e97cb](https://github.com/main-branch/process_executer/commit/61e97cb09c816cbe30d81dc33f268accf76ab396))
45
+ * **run:** Add failing tests for options mutation and caller-owned pipe closing ([cf32615](https://github.com/main-branch/process_executer/commit/cf326156381fe81562597906d43134a08ebbef17))
46
+ * **run:** Add failing tests for pipe creation failure in Run#call ([21521d9](https://github.com/main-branch/process_executer/commit/21521d9a9b5141d9bb6d22fb5fd3e7de0e497ca2))
47
+ * **run:** Update the JRuby expectation for an invalid chdir path ([2712f0b](https://github.com/main-branch/process_executer/commit/2712f0bdf060fa1831edb428183283dec5f2727a))
48
+ * Use RbConfig.ruby to locate the Ruby interpreter ([82e65e2](https://github.com/main-branch/process_executer/commit/82e65e202a4f884a3283b4af00fcd2b5eda3a394))
49
+
8
50
  ## [4.0.4](https://github.com/main-branch/process_executer/compare/v4.0.3...v4.0.4) (2026-04-24)
9
51
 
10
52
 
data/README.md CHANGED
@@ -26,6 +26,7 @@ then click the "Documentation" link.
26
26
  - [Usage](#usage)
27
27
  - [Key methods](#key-methods)
28
28
  - [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
29
+ - [Redirecting stdin](#redirecting-stdin)
29
30
  - [Encoding](#encoding)
30
31
  - [Encoding summary](#encoding-summary)
31
32
  - [Encoding details](#encoding-details)
@@ -119,6 +120,25 @@ supports these additional types of destinations:
119
120
  be any value that `MonitoredPipe` itself supports (including another tee or
120
121
  MonitoredPipe).
121
122
 
123
+ ### Redirecting stdin
124
+
125
+ The `in:` option is passed directly to `Process.spawn` and is not wrapped in a
126
+ `MonitoredPipe`. It accepts any stdin redirection value that `Process.spawn`
127
+ supports, such as a filename (`in: 'input.txt'`) or an IO object with a file
128
+ descriptor. Unlike `out:` and `err:`, redirecting stdin from a `StringIO` is not
129
+ supported and will raise `ProcessExecuter::SpawnError`. To feed in-memory data
130
+ to a command's stdin, write it to a `Tempfile` and pass that:
131
+
132
+ ```ruby
133
+ require 'tempfile'
134
+
135
+ Tempfile.create do |file|
136
+ file.write("HEAD\n")
137
+ file.rewind
138
+ result = ProcessExecuter.run('git cat-file --batch-check', in: file)
139
+ end
140
+ ```
141
+
122
142
  ### Encoding
123
143
 
124
144
  #### Encoding summary
data/Rakefile CHANGED
@@ -6,6 +6,9 @@ desc 'Run the same tasks that the CI build will run'
6
6
 
7
7
  if RUBY_PLATFORM == 'java'
8
8
  task default: %w[spec rubocop bundle:audit build]
9
+ elsif RUBY_ENGINE == 'truffleruby'
10
+ # RuboCop raises internal errors when run on TruffleRuby, so skip it there
11
+ task default: %w[spec yard bundle:audit build]
9
12
  else
10
13
  task default: %w[spec rubocop yard bundle:audit build]
11
14
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
4
+
3
5
  require_relative '../errors'
4
6
  require_relative 'spawn_with_timeout'
5
7
 
@@ -19,9 +21,29 @@ module ProcessExecuter
19
21
  # destination. This means that you can redirect to a StringIO which is not possible
20
22
  # with `Process.spawn`.
21
23
  #
24
+ # The wrapper pipes are kept in an internal hash that is combined with the
25
+ # user's options only when `Process.spawn` is called. The options object the
26
+ # caller gave is never modified, so it can be reused for another run and
27
+ # `result.options` returns the destinations as the user configured them.
28
+ #
22
29
  # @api private
23
30
  #
24
31
  class Run < SpawnWithTimeout
32
+ # Create a new Run instance
33
+ #
34
+ # @example
35
+ # options = ProcessExecuter::Options::RunOptions.new(raise_errors: true)
36
+ # result = ProcessExecuter::Commands::Run.new('echo hello', options).call
37
+ # result.success? # => true
38
+ #
39
+ # @param command [Array<String>] The command to run in the subprocess
40
+ # @param options [ProcessExecuter::Options::RunOptions] The options to use when running the command
41
+ #
42
+ def initialize(command, options)
43
+ super
44
+ @redirection_overrides = {}
45
+ end
46
+
25
47
  # Run a command and return the result
26
48
  #
27
49
  # Wrap the stdout and stderr redirection destinations in pipes and then execute
@@ -44,35 +66,130 @@ module ProcessExecuter
44
66
  # @raise [ProcessExecuter::TimeoutError] If the command timed out
45
67
  #
46
68
  # @raise [ProcessExecuter::ProcessIOError] If there was an exception while
47
- # collecting subprocess output
69
+ # collecting subprocess output, or the output was truncated because it
70
+ # could not be fully collected before the close timeout
48
71
  #
49
72
  # @return [ProcessExecuter::Result] The result of the completed subprocess
50
73
  #
51
74
  def call
52
- opened_pipes = wrap_stdout_stderr
75
+ opened_pipes = {}
76
+ wrap_stdout_stderr(opened_pipes)
53
77
  super.tap do
54
78
  log_result
55
79
  raise_errors if options.raise_errors
56
80
  end
57
81
  ensure
58
- opened_pipes.each_value(&:close)
59
- opened_pipes.each { |option_key, pipe| raise_pipe_error(option_key, pipe) }
82
+ close_pipes_and_check_errors(opened_pipes, $ERROR_INFO)
60
83
  end
61
84
 
62
85
  private
63
86
 
64
- # Wrap the stdout and stderr redirection options with a MonitoredPipe
65
- # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
66
- def wrap_stdout_stderr
67
- options.each_with_object({}) do |key_value, opened_pipes|
68
- key, value = key_value
87
+ # Redirection options to apply on top of the user's options at spawn time
88
+ #
89
+ # Holds the {MonitoredPipe} wrappers created by {#wrap_stdout_stderr} (and
90
+ # the capture redirections added by subclasses) keyed by the redirection
91
+ # option key. Keeping them here instead of writing them into {#options}
92
+ # leaves the caller's options object unmodified.
93
+ #
94
+ # @return [Hash<Object, Object>]
95
+ #
96
+ attr_reader :redirection_overrides
69
97
 
98
+ # The options to pass to Process.spawn
99
+ #
100
+ # The user's spawn options with the redirection destinations replaced by
101
+ # their internal {MonitoredPipe} wrappers.
102
+ #
103
+ # @return [Hash]
104
+ #
105
+ def spawn_options = super.merge(redirection_overrides)
106
+
107
+ # Wrap the stdout and stderr redirection options with a MonitoredPipe
108
+ #
109
+ # The wrapper pipes are recorded in {#redirection_overrides} instead of
110
+ # being written into {#options} so the caller's options object is not
111
+ # modified.
112
+ #
113
+ # Each pipe is added to `opened_pipes` as soon as it is created so that,
114
+ # if creating a later pipe raises, the caller's ensure block can close the
115
+ # pipes created so far.
116
+ #
117
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] an
118
+ # accumulator for the opened pipes (the Object is the option key)
119
+ #
120
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] the given `opened_pipes`
121
+ #
122
+ def wrap_stdout_stderr(opened_pipes)
123
+ effective_redirections.each do |key, value|
70
124
  next unless should_wrap?(key, value)
71
125
 
72
126
  wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
73
127
  opened_pipes[key] = wrapped_destination
74
- options.merge!({ key => wrapped_destination })
128
+ redirection_overrides[key] = wrapped_destination
75
129
  end
130
+ opened_pipes
131
+ end
132
+
133
+ # The options as given by the user with {#redirection_overrides} applied
134
+ #
135
+ # @return [Hash<Object, Object>]
136
+ #
137
+ def effective_redirections = options.to_h.merge(redirection_overrides)
138
+
139
+ # Close the given pipes and raise any pipe error unless already unwinding
140
+ #
141
+ # When `in_flight_error` is set, `#call` is unwinding from an exception
142
+ # and that exception (not a pipe destination error or a pipe cleanup
143
+ # error) must be the one the caller sees, so nothing is raised here.
144
+ #
145
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
146
+ #
147
+ # @param in_flight_error [Exception, nil] the exception `#call` is unwinding from, if any
148
+ #
149
+ # @raise [ProcessExecuter::ProcessIOError] if a pipe recorded a destination
150
+ # exception or gave up draining before reaching EOF (truncated output)
151
+ #
152
+ # @raise [StandardError] the first error raised while closing the pipes
153
+ #
154
+ # @return [void]
155
+ #
156
+ def close_pipes_and_check_errors(opened_pipes, in_flight_error)
157
+ close_error = close_pipes(opened_pipes)
158
+ return if in_flight_error
159
+
160
+ opened_pipes.each do |option_key, pipe|
161
+ raise_pipe_error(option_key, pipe)
162
+ raise_truncation_error(option_key, pipe)
163
+ end
164
+ raise close_error if close_error
165
+ end
166
+
167
+ # Close the given pipes, continuing if closing one of them raises
168
+ #
169
+ # Closing continues past a failure so that one pipe's error does not leak
170
+ # the monitoring threads and file descriptors of the pipes after it.
171
+ #
172
+ # All pipes share a single close deadline so that this method -- called
173
+ # from `#call`'s ensure block -- returns in bounded time even when a
174
+ # process outside this object's control (such as an orphaned descendant
175
+ # of a timed out command) still holds a pipe's write fd open. A pipe
176
+ # whose drain is cut short by the deadline records it via
177
+ # {MonitoredPipe#truncated?}.
178
+ #
179
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
180
+ #
181
+ # @return [StandardError, nil] the first error raised while closing, or nil if none was raised
182
+ #
183
+ def close_pipes(opened_pipes)
184
+ first_close_error = nil
185
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MonitoredPipe::DEFAULT_CLOSE_TIMEOUT
186
+ opened_pipes.each_value do |pipe|
187
+ remaining_time = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ pipe.close(timeout: [remaining_time, 0].max)
189
+ rescue StandardError => e
190
+ first_close_error ||= e
191
+ end
192
+ first_close_error
76
193
  end
77
194
 
78
195
  # Should the redirection option be wrapped by a MonitoredPipe
@@ -119,6 +236,31 @@ module ProcessExecuter
119
236
  error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
120
237
  raise(error, cause: pipe.exception)
121
238
  end
239
+
240
+ # Raises a ProcessIOError if the given pipe's output was truncated
241
+ #
242
+ # Truncation means the pipe gave up draining before reaching EOF
243
+ # ({MonitoredPipe#truncated?}): output the subprocess (or a descendant
244
+ # holding the inherited write fd) produced was discarded instead of
245
+ # being written to the destination. Raising makes that data loss loud
246
+ # rather than letting the command appear to succeed with silently
247
+ # incomplete output.
248
+ #
249
+ # @param option_key [Object] The redirection option key
250
+ #
251
+ # @param pipe [ProcessExecuter::MonitoredPipe] The pipe whose output was truncated
252
+ #
253
+ # @raise [ProcessExecuter::ProcessIOError] If the pipe's output was truncated
254
+ #
255
+ # @return [void]
256
+ #
257
+ def raise_truncation_error(option_key, pipe)
258
+ return unless pipe.truncated?
259
+
260
+ raise ProcessExecuter::ProcessIOError,
261
+ "Output truncated for #{command}: #{option_key.inspect} " \
262
+ 'could not be fully collected before the close timeout'
263
+ end
122
264
  end
123
265
  end
124
266
  end
@@ -50,7 +50,7 @@ module ProcessExecuter
50
50
  @stderr_buffer = StringIO.new
51
51
  stderr_buffer.set_encoding(options.effective_stderr_encoding)
52
52
 
53
- update_capture_options
53
+ add_capture_redirections
54
54
 
55
55
  begin
56
56
  super
@@ -89,15 +89,52 @@ module ProcessExecuter
89
89
  )
90
90
  end
91
91
 
92
- # Updates {options} to include the stdout and stderr capture options
92
+ # Add the stdout and stderr capture redirections to {#redirection_overrides}
93
+ #
94
+ # The capture redirections are not written into {options} so the caller's
95
+ # options object is not modified.
96
+ #
97
+ # When the user gives a combined redirection whose key covers both stdout
98
+ # and stderr (e.g. `[:out, :err] => destination`), a single capture
99
+ # redirection is built for that key so both streams are interleaved into
100
+ # {#stdout_buffer} and {#stderr_buffer} is left empty, mirroring the
101
+ # `merge_output: true` contract.
102
+ #
103
+ # @return [Void]
104
+ #
105
+ def add_capture_redirections
106
+ if options.combined_stdout_and_stderr_redirection?
107
+ add_combined_capture_redirection
108
+ else
109
+ add_stdout_and_stderr_capture_redirections
110
+ end
111
+ end
112
+
113
+ # Add a single capture redirection for a combined stdout/stderr key
114
+ #
115
+ # Both streams are interleaved into {#stdout_buffer}; {#stderr_buffer} is
116
+ # left empty.
117
+ #
118
+ # @return [Void]
119
+ #
120
+ def add_combined_capture_redirection
121
+ redirection_overrides.merge!(
122
+ capture_option(:out, stdout_redirection_source, stdout_redirection_destination, stdout_buffer)
123
+ )
124
+ end
125
+
126
+ # Add separate capture redirections for stdout and stderr
127
+ #
128
+ # If `merge_output: true` was given, stderr is redirected into stdout so
129
+ # both streams are interleaved into {#stdout_buffer}.
93
130
  #
94
131
  # @return [Void]
95
132
  #
96
- def update_capture_options
133
+ def add_stdout_and_stderr_capture_redirections
97
134
  out = stdout_buffer
98
135
  err = options.merge_output ? [:child, 1] : stderr_buffer
99
136
 
100
- options.merge!(
137
+ redirection_overrides.merge!(
101
138
  capture_option(:out, stdout_redirection_source, stdout_redirection_destination, out),
102
139
  capture_option(:err, stderr_redirection_source, stderr_redirection_destination, err)
103
140
  )
@@ -48,7 +48,7 @@ module ProcessExecuter
48
48
  #
49
49
  def call
50
50
  begin
51
- @pid = Process.spawn(*command, **options.spawn_options)
51
+ @pid = Process.spawn(*command, **spawn_options)
52
52
  rescue StandardError => e
53
53
  raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}"
54
54
  end
@@ -80,10 +80,14 @@ module ProcessExecuter
80
80
 
81
81
  # The status returned by Process.wait2
82
82
  #
83
+ # nil when the timeout was delivered after the wait had already reaped
84
+ # the subprocess: the status was lost to the raise and {#timed_out?} is
85
+ # true.
86
+ #
83
87
  # @example
84
88
  # spawn.status #=> #<Process::Status: pid 12345 exit 0>
85
89
  #
86
- # @return [Process::Status]
90
+ # @return [Process::Status, nil]
87
91
  #
88
92
  attr_reader :status
89
93
 
@@ -118,6 +122,47 @@ module ProcessExecuter
118
122
 
119
123
  private
120
124
 
125
+ # The options to pass to Process.spawn
126
+ #
127
+ # Subclasses may override this method to combine internal redirections
128
+ # with the user's options without modifying the options object the
129
+ # caller gave.
130
+ #
131
+ # @return [Hash]
132
+ #
133
+ def spawn_options = options.spawn_options.merge(process_group_options)
134
+
135
+ # Spawn options that place the subprocess into its own process group
136
+ #
137
+ # When `timeout_after` is set to a value that can fire (`nil` and `0`
138
+ # mean "no timeout"), the subprocess is made the leader of a new process
139
+ # group so that a timeout can kill the whole group -- including
140
+ # descendants that inherited the redirections -- instead of just the
141
+ # direct child. Empty when no timeout can fire or when the caller gave a
142
+ # `pgroup`/`new_pgroup` option themselves (their setting is honored).
143
+ #
144
+ # A new process group is a background group for any terminal the
145
+ # subprocess inherits, so an interactive subprocess that reads the
146
+ # terminal is stopped by `SIGTTIN` and then killed when the timeout
147
+ # fires -- which is the bound `timeout_after` promises. A caller who
148
+ # needs an interactive subprocess to stay in the foreground process
149
+ # group can pass their own `pgroup` option.
150
+ #
151
+ # @return [Hash]
152
+ #
153
+ def process_group_options
154
+ return {} unless options.timeout_after&.positive?
155
+ return {} unless options.pgroup == :not_set && options.new_pgroup == :not_set
156
+
157
+ windows? ? { new_pgroup: true } : { pgroup: true }
158
+ end
159
+
160
+ # Whether the current platform is Windows
161
+ #
162
+ # @return [Boolean]
163
+ #
164
+ def windows? = Gem.win_platform?
165
+
121
166
  # Wait for process to terminate
122
167
  #
123
168
  # If a `:timeout_after` is specified in options, terminate the process after the
@@ -142,21 +187,145 @@ module ProcessExecuter
142
187
 
143
188
  # Wait for a process to terminate returning the status and timed out flag
144
189
  #
145
- # @return [Array<Process::Status, Boolean>] an array containing the process status and a boolean
146
- # indicating whether the process timed out
190
+ # An exception other than the timeout (an `Interrupt` from Ctrl-C, for
191
+ # example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then
192
+ # cleans up a subprocess this class isolated into its own process group
193
+ # before the exception propagates.
194
+ #
195
+ # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
196
+ # the process status (nil when the timeout raced the wait and the status was lost,
197
+ # see {#wait_with_timeout}) and a boolean indicating whether the process timed out
147
198
  def wait_for_process_raw
148
- timed_out = false
149
-
150
- process_status =
151
- begin
152
- Timeout.timeout(options.timeout_after) { Process.wait2(pid).last }
153
- rescue Timeout::Error
154
- Process.kill('KILL', pid)
155
- timed_out = true
156
- Process.wait2(pid).last
157
- end
158
-
159
- [process_status, timed_out]
199
+ wait_with_timeout
200
+ rescue Exception # rubocop:disable Lint/RescueException
201
+ kill_and_reap_abandoned_subprocess
202
+ raise
203
+ end
204
+
205
+ # Wait for the process, killing it when `timeout_after` expires first
206
+ #
207
+ # The timeout can be delivered after the timed wait has already reaped
208
+ # the subprocess but before it returns. In that race the subprocess's
209
+ # status was lost to the raise, so the status is nil and the timed out
210
+ # flag is still set.
211
+ #
212
+ # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
213
+ # the process status (nil when the timeout raced the wait and the status was lost)
214
+ # and a boolean indicating whether the process timed out
215
+ def wait_with_timeout
216
+ process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last }
217
+ [process_status, false]
218
+ rescue Timeout::Error
219
+ kill_subprocess
220
+ begin
221
+ [Process.wait2(pid).last, true]
222
+ rescue Errno::ECHILD
223
+ # the interrupted wait already reaped the subprocess; its status was
224
+ # lost to the raise
225
+ [nil, true]
226
+ end
227
+ end
228
+
229
+ # Kill and reap the subprocess when its wait was abandoned by an exception
230
+ #
231
+ # Only applies to a subprocess this class isolated into its own process
232
+ # group: such a subprocess no longer receives terminal-generated signals
233
+ # (Ctrl-C sends `SIGINT` to the caller's foreground group, not to the
234
+ # new group), so an exception that abandons the wait would otherwise
235
+ # leave it and its descendants running unsupervised and unreaped. A
236
+ # subprocess whose process group came from the caller's own options
237
+ # keeps its pre-existing signal semantics and is left alone.
238
+ #
239
+ # Rescues `Exception` (not just `StandardError`) so that a second async
240
+ # exception delivered during this best-effort cleanup cannot replace
241
+ # the exception already being re-raised by the caller.
242
+ #
243
+ # @return [void]
244
+ #
245
+ def kill_and_reap_abandoned_subprocess
246
+ return unless isolated_in_new_process_group?
247
+
248
+ kill_subprocess
249
+ Process.wait2(pid)
250
+ rescue Exception # rubocop:disable Lint/RescueException
251
+ # the subprocess may already be dead and reaped; the wait's exception
252
+ # is what must propagate
253
+ end
254
+
255
+ # Forcibly terminate the timed out subprocess and (if possible) its descendants
256
+ #
257
+ # When the subprocess was spawned as the leader of its own process
258
+ # group, the whole group is killed so that descendants that would
259
+ # otherwise survive the timeout (and keep any inherited redirection
260
+ # file descriptors open) are terminated too, falling back to killing
261
+ # the direct child if the group kill fails. A group signal reaches only
262
+ # the processes still in that group: a descendant that started its own
263
+ # session or joined another process group (a daemon, for example) is
264
+ # not killed. Otherwise the subprocess is in a process group this
265
+ # object did not create, so only the direct child is killed, matching
266
+ # the pre-process-group behavior.
267
+ #
268
+ # Killing a process group is only possible on POSIX platforms. On
269
+ # Windows, `Process.kill` cannot signal a process group (a negative pid
270
+ # raises an error), so the group kill always falls back to the direct
271
+ # child and descendants may survive the timeout; the bounded
272
+ # {MonitoredPipe#close} keeps such descendants from blocking
273
+ # {ProcessExecuter.run} indefinitely.
274
+ #
275
+ # A subprocess that already exited and was reaped before the signal is
276
+ # sent (the timeout racing the wait) leaves nothing to kill; that is not
277
+ # an error. In that same microsecond window the freed pid could in
278
+ # principle be recycled to an unrelated process, a hazard inherent to
279
+ # signaling by pid: Ruby exposes no race-free process handle (such as
280
+ # Linux's pidfd) that would eliminate it, and reuse would require the OS
281
+ # to cycle through its entire pid space within the window.
282
+ #
283
+ # @return [void]
284
+ #
285
+ def kill_subprocess
286
+ return if process_group_leader? && kill_process_group
287
+
288
+ Process.kill('KILL', pid)
289
+ rescue Errno::ESRCH
290
+ # the subprocess already exited and was reaped between the interrupted
291
+ # wait and the kill; there is nothing left to kill
292
+ end
293
+
294
+ # Whether the spawn options made the subprocess a new process group leader
295
+ #
296
+ # True when the process group option -- added by {#process_group_options}
297
+ # or given by the caller -- asks for a new process group with the
298
+ # subprocess as its leader (`pgroup: true`, `pgroup: 0`, or
299
+ # `new_pgroup: true`). False when there is no process group option or
300
+ # when `pgroup` places the subprocess in an existing process group.
301
+ #
302
+ # @return [Boolean]
303
+ #
304
+ def process_group_leader?
305
+ [true, 0].include?(spawn_options[:pgroup]) || spawn_options[:new_pgroup] == true
306
+ end
307
+
308
+ # Whether this class isolated the subprocess into its own process group
309
+ #
310
+ # True when the subprocess is a new process group leader and that came
311
+ # from {#process_group_options} rather than from a `pgroup`/`new_pgroup`
312
+ # option the caller supplied.
313
+ #
314
+ # @return [Boolean]
315
+ #
316
+ def isolated_in_new_process_group?
317
+ process_group_leader? && options.pgroup == :not_set && options.new_pgroup == :not_set
318
+ end
319
+
320
+ # Send SIGKILL to the subprocess's process group
321
+ #
322
+ # @return [Boolean] true if the signal was sent, false if doing so raised an error
323
+ #
324
+ def kill_process_group
325
+ Process.kill('KILL', -pid)
326
+ true
327
+ rescue StandardError
328
+ false
160
329
  end
161
330
  end
162
331
  end