process_executer 4.1.0 → 4.1.1

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: 63acfcf3541790f98d140feac5a9badc901f8b4e475b3c97d125f6b549556bfc
4
- data.tar.gz: 7c42a5cfa8b66fea530be0821443a974c1fabc1002fc2b3f2f35e533812c9c6e
3
+ metadata.gz: a6dbea0ce77694bd73783f7191fc9b32957fefda87f9db6108d97ebf26bb1a9f
4
+ data.tar.gz: e710378f301c56c649063d6bfef0525f0c0c8dcf6adf6329e12184ec24d02894
5
5
  SHA512:
6
- metadata.gz: b8d54aa2b9c5e1c153a4c92dbaeec6d96c3b80f31f6517ad2026519d070eff6d6c5b6fdc447cfbff481a6c15599c06ace734068f784e1f1ca8ba78831e84e0d7
7
- data.tar.gz: fef70d1e6342139152e498dd72572f591f5f330f8617e7ad0603898638c08db8e2ec09aeb302e486949432d80a7635611654959869314946df13c1bf87ed4dd0
6
+ metadata.gz: 756a9aacc5d173fa2cedd68f178c9e321e141d3ef75f61ddcfe7e3ebe67f236a0603192f24c49aaedbc0dcf209d61dd16b593477f67d974fa1f2e875ec17dd7d
7
+ data.tar.gz: a9ce354984f37839c54abfb453eef726d6467467dc529aaa2108643c2e153005022b097b9a1ecfea368391ff9ebbf8ba20b32b9cd73db11c5076394824d81528
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "4.1.0"
2
+ ".": "4.1.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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.1](https://github.com/main-branch/process_executer/compare/v4.1.0...v4.1.1) (2026-08-31)
9
+
10
+
11
+ ### Other Changes
12
+
13
+ * Characterize validation ordering when unknown and invalid options are given together ([#192](https://github.com/main-branch/process_executer/issues/192)) ([15d6e06](https://github.com/main-branch/process_executer/commit/15d6e063170484f309efd30867238b867484288b))
14
+ * Collapse encoding validators and clarify capture-redirection assembly ([#194](https://github.com/main-branch/process_executer/issues/194)) ([bab8610](https://github.com/main-branch/process_executer/commit/bab86101ac4cdbd3747e72d0d7f841623a6e9b20))
15
+ * Contribute subclass redirections via an internal_redirections hook ([#190](https://github.com/main-branch/process_executer/issues/190)) ([8ecbda5](https://github.com/main-branch/process_executer/commit/8ecbda54874de933ab07a64d91e0a07e17f7ece0))
16
+ * Derive the process-group isolation decision from a single source ([#191](https://github.com/main-branch/process_executer/issues/191)) ([5d8ac7d](https://github.com/main-branch/process_executer/commit/5d8ac7dc38a369a7fe6d6cf3d05bd1eec799f3dc))
17
+ * Describe every source of MonitoredPipe#exception ([#193](https://github.com/main-branch/process_executer/issues/193)) ([333b7bd](https://github.com/main-branch/process_executer/commit/333b7bd4c552c246d7d7505b5aa3e1a123ac5f9b))
18
+ * Extract shared validate! from Options::Base#initialize and #merge! ([#192](https://github.com/main-branch/process_executer/issues/192)) ([422956a](https://github.com/main-branch/process_executer/commit/422956acaab46e29ecc1373d017d719ea6eb497f))
19
+ * Remove the release-as 4.1.0 override from the release-please config ([65de5b8](https://github.com/main-branch/process_executer/commit/65de5b8b4a7f926c4e71b094ead5ff9abc6ff102))
20
+ * Track opened pipes as per-call instance state instead of an out-parameter ([#190](https://github.com/main-branch/process_executer/issues/190)) ([f279f1e](https://github.com/main-branch/process_executer/commit/f279f1e0672b91b966ee73cbadf00749522e488d))
21
+
8
22
  ## [4.1.0](https://github.com/main-branch/process_executer/compare/v4.0.4...v4.1.0) (2026-08-30)
9
23
 
10
24
 
@@ -21,29 +21,16 @@ module ProcessExecuter
21
21
  # destination. This means that you can redirect to a StringIO which is not possible
22
22
  # with `Process.spawn`.
23
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.
24
+ # The wrapper pipes are kept in internal per-call state that is combined
25
+ # with the user's options only when `Process.spawn` is called. Subclasses
26
+ # contribute additional redirections by overriding `#internal_redirections`.
27
+ # The options object the caller gave is never modified, so it can be reused
28
+ # for another run and `result.options` returns the destinations as the user
29
+ # configured them.
28
30
  #
29
31
  # @api private
30
32
  #
31
33
  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
-
47
34
  # Run a command and return the result
48
35
  #
49
36
  # Wrap the stdout and stderr redirection destinations in pipes and then execute
@@ -72,29 +59,60 @@ module ProcessExecuter
72
59
  # @return [ProcessExecuter::Result] The result of the completed subprocess
73
60
  #
74
61
  def call
75
- opened_pipes = {}
76
- wrap_stdout_stderr(opened_pipes)
62
+ @opened_pipes = {}
63
+ @redirection_overrides = internal_redirections
64
+ wrap_stdout_stderr
77
65
  super.tap do
78
66
  log_result
79
67
  raise_errors if options.raise_errors
80
68
  end
81
69
  ensure
82
- close_pipes_and_check_errors(opened_pipes, $ERROR_INFO)
70
+ close_pipes_and_check_errors($ERROR_INFO)
83
71
  end
84
72
 
85
73
  private
86
74
 
87
75
  # Redirection options to apply on top of the user's options at spawn time
88
76
  #
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}
77
+ # Reset at the start of each {#call}: seeded with
78
+ # {#internal_redirections} and then updated by {#wrap_stdout_stderr},
79
+ # which replaces each eligible destination with its {MonitoredPipe}
80
+ # wrapper. Keeping these here instead of writing them into {#options}
92
81
  # leaves the caller's options object unmodified.
93
82
  #
83
+ # Unlike {#opened_pipes}, this hash may hold values that are not
84
+ # {MonitoredPipe}s (for example, the `[:child, 1]` redirection a subclass
85
+ # adds for merged output).
86
+ #
94
87
  # @return [Hash<Object, Object>]
95
88
  #
96
89
  attr_reader :redirection_overrides
97
90
 
91
+ # Redirections this class adds on top of the user's options
92
+ #
93
+ # Called once at the start of each {#call} to seed
94
+ # {#redirection_overrides}, before {#wrap_stdout_stderr} runs. Returns an
95
+ # empty hash; subclasses override this method to contribute their own
96
+ # redirections (such as {RunWithCapture}'s capture redirections) instead
97
+ # of mutating this object's state.
98
+ #
99
+ # @return [Hash<Object, Object>]
100
+ #
101
+ def internal_redirections = {}
102
+
103
+ # The wrapper pipes created by {#wrap_stdout_stderr}
104
+ #
105
+ # Keyed by the redirection option key. Reset at the start of each {#call}. By construction this hash contains
106
+ # exactly the pipes this object created during the current call -- never
107
+ # a destination supplied by the caller -- so `#call`'s ensure block can
108
+ # close everything in it without ever closing a caller-owned pipe (a
109
+ # caller's own {MonitoredPipe} destination is wrapped like any other
110
+ # destination, and only the wrapper is recorded here).
111
+ #
112
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>]
113
+ #
114
+ attr_reader :opened_pipes
115
+
98
116
  # The options to pass to Process.spawn
99
117
  #
100
118
  # The user's spawn options with the redirection destinations replaced by
@@ -106,20 +124,19 @@ module ProcessExecuter
106
124
 
107
125
  # Wrap the stdout and stderr redirection options with a MonitoredPipe
108
126
  #
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.
127
+ # Each wrapper pipe is recorded in two collections with different roles:
128
+ # {#opened_pipes}, the pipes this object owns and must close, and
129
+ # {#redirection_overrides}, the destinations to hand `Process.spawn`.
130
+ # Neither is written into {#options}, so the caller's options object is
131
+ # not modified.
116
132
  #
117
- # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] an
118
- # accumulator for the opened pipes (the Object is the option key)
133
+ # Each pipe is recorded as soon as it is created so that, if creating a
134
+ # later pipe raises, `#call`'s ensure block can close the pipes created
135
+ # so far.
119
136
  #
120
- # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] the given `opened_pipes`
137
+ # @return [void]
121
138
  #
122
- def wrap_stdout_stderr(opened_pipes)
139
+ def wrap_stdout_stderr
123
140
  effective_redirections.each do |key, value|
124
141
  next unless should_wrap?(key, value)
125
142
 
@@ -127,7 +144,6 @@ module ProcessExecuter
127
144
  opened_pipes[key] = wrapped_destination
128
145
  redirection_overrides[key] = wrapped_destination
129
146
  end
130
- opened_pipes
131
147
  end
132
148
 
133
149
  # The options as given by the user with {#redirection_overrides} applied
@@ -136,14 +152,12 @@ module ProcessExecuter
136
152
  #
137
153
  def effective_redirections = options.to_h.merge(redirection_overrides)
138
154
 
139
- # Close the given pipes and raise any pipe error unless already unwinding
155
+ # Close the opened pipes and raise any pipe error unless already unwinding
140
156
  #
141
157
  # When `in_flight_error` is set, `#call` is unwinding from an exception
142
158
  # and that exception (not a pipe destination error or a pipe cleanup
143
159
  # error) must be the one the caller sees, so nothing is raised here.
144
160
  #
145
- # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
146
- #
147
161
  # @param in_flight_error [Exception, nil] the exception `#call` is unwinding from, if any
148
162
  #
149
163
  # @raise [ProcessExecuter::ProcessIOError] if a pipe recorded a destination
@@ -153,8 +167,8 @@ module ProcessExecuter
153
167
  #
154
168
  # @return [void]
155
169
  #
156
- def close_pipes_and_check_errors(opened_pipes, in_flight_error)
157
- close_error = close_pipes(opened_pipes)
170
+ def close_pipes_and_check_errors(in_flight_error)
171
+ close_error = close_pipes
158
172
  return if in_flight_error
159
173
 
160
174
  opened_pipes.each do |option_key, pipe|
@@ -164,7 +178,7 @@ module ProcessExecuter
164
178
  raise close_error if close_error
165
179
  end
166
180
 
167
- # Close the given pipes, continuing if closing one of them raises
181
+ # Close the opened pipes, continuing if closing one of them raises
168
182
  #
169
183
  # Closing continues past a failure so that one pipe's error does not leak
170
184
  # the monitoring threads and file descriptors of the pipes after it.
@@ -176,11 +190,9 @@ module ProcessExecuter
176
190
  # whose drain is cut short by the deadline records it via
177
191
  # {MonitoredPipe#truncated?}.
178
192
  #
179
- # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
180
- #
181
193
  # @return [StandardError, nil] the first error raised while closing, or nil if none was raised
182
194
  #
183
- def close_pipes(opened_pipes)
195
+ def close_pipes
184
196
  first_close_error = nil
185
197
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MonitoredPipe::DEFAULT_CLOSE_TIMEOUT
186
198
  opened_pipes.each_value do |pipe|
@@ -50,8 +50,6 @@ module ProcessExecuter
50
50
  @stderr_buffer = StringIO.new
51
51
  stderr_buffer.set_encoding(options.effective_stderr_encoding)
52
52
 
53
- add_capture_redirections
54
-
55
53
  begin
56
54
  super
57
55
  ensure
@@ -89,10 +87,11 @@ module ProcessExecuter
89
87
  )
90
88
  end
91
89
 
92
- # Add the stdout and stderr capture redirections to {#redirection_overrides}
90
+ # The stdout and stderr capture redirections
93
91
  #
94
- # The capture redirections are not written into {options} so the caller's
95
- # options object is not modified.
92
+ # Called by {Run#call} to seed the redirection overrides, so the capture
93
+ # redirections are layered on top of the user's options at spawn time
94
+ # without writing into {#options} or mutating the parent's state.
96
95
  #
97
96
  # When the user gives a combined redirection whose key covers both stdout
98
97
  # and stderr (e.g. `[:out, :err] => destination`), a single capture
@@ -100,80 +99,98 @@ module ProcessExecuter
100
99
  # {#stdout_buffer} and {#stderr_buffer} is left empty, mirroring the
101
100
  # `merge_output: true` contract.
102
101
  #
103
- # @return [Void]
102
+ # @return [Hash<Object, Object>]
104
103
  #
105
- def add_capture_redirections
104
+ def internal_redirections
106
105
  if options.combined_stdout_and_stderr_redirection?
107
- add_combined_capture_redirection
106
+ combined_capture_redirection
108
107
  else
109
- add_stdout_and_stderr_capture_redirections
108
+ stdout_and_stderr_capture_redirections
110
109
  end
111
110
  end
112
111
 
113
- # Add a single capture redirection for a combined stdout/stderr key
112
+ # A single capture redirection for a combined stdout/stderr key
114
113
  #
115
114
  # Both streams are interleaved into {#stdout_buffer}; {#stderr_buffer} is
116
115
  # left empty.
117
116
  #
118
- # @return [Void]
117
+ # @return [Hash<Object, Object>]
119
118
  #
120
- def add_combined_capture_redirection
121
- redirection_overrides.merge!(
122
- capture_option(:out, stdout_redirection_source, stdout_redirection_destination, stdout_buffer)
119
+ def combined_capture_redirection
120
+ tee_capture_redirection(
121
+ options.stdout_redirection_source, options.stdout_redirection_destination, stdout_buffer
123
122
  )
124
123
  end
125
124
 
126
- # Add separate capture redirections for stdout and stderr
125
+ # Separate capture redirections for stdout and stderr
127
126
  #
128
127
  # If `merge_output: true` was given, stderr is redirected into stdout so
129
128
  # both streams are interleaved into {#stdout_buffer}.
130
129
  #
131
- # @return [Void]
130
+ # @return [Hash<Object, Object>]
132
131
  #
133
- def add_stdout_and_stderr_capture_redirections
134
- out = stdout_buffer
135
- err = options.merge_output ? [:child, 1] : stderr_buffer
136
-
137
- redirection_overrides.merge!(
138
- capture_option(:out, stdout_redirection_source, stdout_redirection_destination, out),
139
- capture_option(:err, stderr_redirection_source, stderr_redirection_destination, err)
140
- )
132
+ def stdout_and_stderr_capture_redirections
133
+ stdout_capture_redirection.merge(stderr_capture_redirection)
141
134
  end
142
135
 
143
- # The source for stdout redirection
144
- # @return [Object]
145
- def stdout_redirection_source = options.stdout_redirection_source
136
+ # The redirection that captures stdout into {#stdout_buffer}
137
+ #
138
+ # Tees the buffer onto the user's stdout redirection if one was given;
139
+ # otherwise installs the plain default capture redirection.
140
+ #
141
+ # @return [Hash<Object, Object>]
142
+ #
143
+ def stdout_capture_redirection
144
+ source = options.stdout_redirection_source
145
+ return default_capture_redirection(:out, stdout_buffer) unless source
146
146
 
147
- # The source for stderr redirection
148
- # @return [Object]
149
- def stderr_redirection_source = options.stderr_redirection_source
147
+ tee_capture_redirection(source, options.stdout_redirection_destination, stdout_buffer)
148
+ end
150
149
 
151
- # The destination for stdout redirection
152
- # @return [Object]
153
- def stdout_redirection_destination = options.stdout_redirection_destination
150
+ # The redirection that captures stderr
151
+ #
152
+ # Stderr is captured into {#stderr_buffer}, or into stdout (and thereby
153
+ # {#stdout_buffer}) if `merge_output: true` was given. Tees the capture
154
+ # destination onto the user's stderr redirection if one was given;
155
+ # otherwise installs the plain default capture redirection.
156
+ #
157
+ # @return [Hash<Object, Object>]
158
+ #
159
+ def stderr_capture_redirection
160
+ capture_destination = options.merge_output ? [:child, 1] : stderr_buffer
161
+ source = options.stderr_redirection_source
162
+ return default_capture_redirection(:err, capture_destination) unless source
154
163
 
155
- # The destination for stderr redirection
156
- # @return [Object]
157
- def stderr_redirection_destination = options.stderr_redirection_destination
164
+ tee_capture_redirection(source, options.stderr_redirection_destination, capture_destination)
165
+ end
158
166
 
159
- # Add the capture redirection to existing options (if any)
160
- # @param redirection_source [Symbol, Integer] The source of the redirection (e.g., :out, :err)
161
- # @param given_source [Symbol, Integer, nil] The source provided by the user (if any)
162
- # @param given_destination [Object, nil] The destination provided by the user (if any)
167
+ # Tee the capture destination onto the redirection the user gave
168
+ #
169
+ # If the user's destination is already a tee, the capture destination is
170
+ # added to it; otherwise the user's destination and the capture
171
+ # destination are wrapped in a new tee.
172
+ #
173
+ # @param source [Symbol, Integer, Array] The redirection source the user gave
174
+ # @param destination [Object] The redirection destination the user gave
163
175
  # @param capture_destination [Object] The additional destination to capture output to
164
- # @return [Hash] The option (including the capture_destination) to merge into options
165
- def capture_option(redirection_source, given_source, given_destination, capture_destination)
166
- if given_source
167
- if Destinations::Tee.handles?(given_destination)
168
- { given_source => given_destination + [capture_destination] }
169
- else
170
- { given_source => [:tee, given_destination, capture_destination] }
171
- end
176
+ # @return [Hash] The redirection to merge into options
177
+ def tee_capture_redirection(source, destination, capture_destination)
178
+ if Destinations::Tee.handles?(destination)
179
+ { source => destination + [capture_destination] }
172
180
  else
173
- { redirection_source => capture_destination }
181
+ { source => [:tee, destination, capture_destination] }
174
182
  end
175
183
  end
176
184
 
185
+ # The plain capture redirection used when the user gave no redirection
186
+ #
187
+ # @param source [Symbol] The redirection source (:out or :err)
188
+ # @param capture_destination [Object] The destination to capture output to
189
+ # @return [Hash] The redirection to merge into options
190
+ def default_capture_redirection(source, capture_destination)
191
+ { source => capture_destination }
192
+ end
193
+
177
194
  # Log the captured command output to the given logger at debug level
178
195
  # @return [Void]
179
196
  def log_command_output
@@ -48,7 +48,8 @@ module ProcessExecuter
48
48
  #
49
49
  def call
50
50
  begin
51
- @pid = Process.spawn(*command, **spawn_options)
51
+ @effective_spawn_options = spawn_options
52
+ @pid = Process.spawn(*command, **effective_spawn_options)
52
53
  rescue StandardError => e
53
54
  raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}"
54
55
  end
@@ -132,6 +133,17 @@ module ProcessExecuter
132
133
  #
133
134
  def spawn_options = options.spawn_options.merge(process_group_options)
134
135
 
136
+ # The spawn options that were passed to Process.spawn
137
+ #
138
+ # Captured once by {#call} -- after any {#spawn_options} additions a
139
+ # subclass contributed -- so the kill path inspects the options actually
140
+ # used instead of recomputing the merge. nil until {#call} spawns the
141
+ # subprocess; the kill path only runs after that.
142
+ #
143
+ # @return [Hash, nil]
144
+ #
145
+ attr_reader :effective_spawn_options
146
+
135
147
  # Spawn options that place the subprocess into its own process group
136
148
  #
137
149
  # When `timeout_after` is set to a value that can fire (`nil` and `0`
@@ -141,6 +153,13 @@ module ProcessExecuter
141
153
  # direct child. Empty when no timeout can fire or when the caller gave a
142
154
  # `pgroup`/`new_pgroup` option themselves (their setting is honored).
143
155
  #
156
+ # This method never reflects a subclass's {#spawn_options} override, so
157
+ # {#isolated_in_new_process_group?} never counts an option a subclass
158
+ # contributes as isolation by this class -- though such an option can
159
+ # still make the subprocess a process group leader (see
160
+ # {#process_group_leader?}) -- and a subclass that removes the option
161
+ # added here prevents the isolation (and its cleanup) altogether.
162
+ #
144
163
  # A new process group is a background group for any terminal the
145
164
  # subprocess inherits, so an interactive subprocess that reads the
146
165
  # terminal is stopped by `SIGTTIN` and then killed when the timeout
@@ -148,6 +167,11 @@ module ProcessExecuter
148
167
  # needs an interactive subprocess to stay in the foreground process
149
168
  # group can pass their own `pgroup` option.
150
169
  #
170
+ # Deterministic: the result depends only on {#options} -- not mutated
171
+ # during {#call} -- and the platform, so the kill path's
172
+ # {#isolated_in_new_process_group?} re-read agrees with the value that
173
+ # was merged into the spawn options.
174
+ #
151
175
  # @return [Hash]
152
176
  #
153
177
  def process_group_options
@@ -302,19 +326,25 @@ module ProcessExecuter
302
326
  # @return [Boolean]
303
327
  #
304
328
  def process_group_leader?
305
- [true, 0].include?(spawn_options[:pgroup]) || spawn_options[:new_pgroup] == true
329
+ [true, 0].include?(effective_spawn_options[:pgroup]) || effective_spawn_options[:new_pgroup] == true
306
330
  end
307
331
 
308
332
  # Whether this class isolated the subprocess into its own process group
309
333
  #
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.
334
+ # True when {#process_group_options} -- the single source of truth for
335
+ # the isolation decision -- added a process group option and the
336
+ # subprocess actually became a new process group leader
337
+ # ({#process_group_leader?} over the captured options). The leader
338
+ # check matters only when a subclass's {#spawn_options} override
339
+ # removed or overrode the added option: then no isolation happened and
340
+ # the abandoned-wait cleanup must leave the subprocess alone. False
341
+ # when the subprocess's process group (if any) came from a
342
+ # `pgroup`/`new_pgroup` option the caller supplied.
313
343
  #
314
344
  # @return [Boolean]
315
345
  #
316
346
  def isolated_in_new_process_group?
317
- process_group_leader? && options.pgroup == :not_set && options.new_pgroup == :not_set
347
+ !process_group_options.empty? && process_group_leader?
318
348
  end
319
349
 
320
350
  # Send SIGKILL to the subprocess's process group
@@ -44,9 +44,10 @@ module ProcessExecuter
44
44
  # a thread is created to read data written to the pipe. As data is read from the pipe,
45
45
  # it is written to the destination provided in the MonitoredPipe initializer.
46
46
  #
47
- # If the destination raises an exception (of any class, not just
48
- # `StandardError`), the monitoring thread will exit, the pipe will be closed,
49
- # and the exception will be saved in `#exception`.
47
+ # If an exception (of any class, not just `StandardError`) is raised while
48
+ # collecting output -- by the destination's `#write`, by the monitor loop,
49
+ # or by pipe cleanup -- the monitoring thread exits, the pipe is closed, and
50
+ # the exception is saved in {#exception}.
50
51
  #
51
52
  # > **⚠️ WARNING**
52
53
  # >
@@ -347,14 +348,22 @@ module ProcessExecuter
347
348
 
348
349
  # @!attribute [r]
349
350
  #
350
- # The exception raised by a destination
351
+ # The first exception recorded while collecting output
351
352
  #
352
- # If an exception is raised by a destination, it is stored here. Otherwise, it is `nil`.
353
+ # Any failure while collecting output is recorded here, not only a
354
+ # destination error: the destination's `#write` raising, the monitor loop
355
+ # raising, or pipe cleanup raising. `nil` if no exception was raised.
356
+ #
357
+ # When more than one exception is raised, the first one *recorded* wins
358
+ # and the rest are discarded. Recording order has one corner: when the
359
+ # monitor loop raises and pipe cleanup then also raises, the cleanup
360
+ # error is the one recorded, because the monitor-loop exception is still
361
+ # in flight while cleanup runs and reaches its recording site last.
353
362
  #
354
363
  # @example
355
364
  # pipe.exception #=> nil
356
365
  #
357
- # @return [Exception, nil] the exception raised by a destination or `nil` if no exception was raised
366
+ # @return [Exception, nil] the first recorded exception or `nil` if no exception was raised
358
367
  #
359
368
  attr_reader :exception
360
369
 
@@ -62,10 +62,8 @@ module ProcessExecuter
62
62
  #
63
63
  def initialize(**options_hash)
64
64
  @options_hash = allowed_options.transform_values(&:default).merge(options_hash)
65
- @errors = []
66
- assert_no_unknown_options
67
65
  define_accessor_methods
68
- validate_options
66
+ validate!
69
67
  end
70
68
 
71
69
  # All the allowed options as a hash whose keys are the option names
@@ -183,13 +181,10 @@ module ProcessExecuter
183
181
  def merge!(*other_options_hashes)
184
182
  original_options_hash = @options_hash
185
183
  @options_hash = original_options_hash.dup.merge!(*other_options_hashes)
186
- @errors = []
187
- assert_no_unknown_options
188
- validate_options
184
+ validate!
189
185
  self
190
186
  rescue ProcessExecuter::ArgumentError
191
187
  @options_hash = original_options_hash
192
- @errors = []
193
188
  raise
194
189
  end
195
190
 
@@ -262,6 +257,23 @@ module ProcessExecuter
262
257
  #
263
258
  attr_reader :options_hash
264
259
 
260
+ # Run the full validation sequence over the current options hash
261
+ #
262
+ # Checks for unknown options and then validates option values, leaving
263
+ # `errors` empty on exit whether validation passes or raises.
264
+ #
265
+ # @return [void]
266
+ # @raise [ProcessExecuter::ArgumentError] if an unknown option or an
267
+ # invalid option value is found
268
+ # @api private
269
+ def validate!
270
+ @errors = []
271
+ assert_no_unknown_options
272
+ validate_options
273
+ ensure
274
+ @errors = []
275
+ end
276
+
265
277
  # Raise an argument error for invalid option values
266
278
  # @return [void]
267
279
  # @raise [ProcessExecuter::ArgumentError] if any invalid option values are found
@@ -296,7 +308,7 @@ module ProcessExecuter
296
308
  return if unknown_options.empty?
297
309
 
298
310
  raise(
299
- ArgumentError,
311
+ ProcessExecuter::ArgumentError,
300
312
  "Unknown option#{'s' if unknown_options.count > 1}: #{unknown_options.join(', ')}"
301
313
  )
302
314
  end
@@ -31,9 +31,7 @@ module ProcessExecuter
31
31
  #
32
32
  # @api private
33
33
  #
34
- def effective_stdout_encoding
35
- canonical_encoding(stdout_encoding || encoding || DEFAULT_ENCODING)
36
- end
34
+ def effective_stdout_encoding = effective_encoding(stdout_encoding)
37
35
 
38
36
  # Determines the character encoding to use for stderr
39
37
  #
@@ -49,12 +47,27 @@ module ProcessExecuter
49
47
  #
50
48
  # @api private
51
49
  #
52
- def effective_stderr_encoding
53
- canonical_encoding(stderr_encoding || encoding || DEFAULT_ENCODING)
54
- end
50
+ def effective_stderr_encoding = effective_encoding(stderr_encoding)
55
51
 
56
52
  private
57
53
 
54
+ # The encoding to use for a stream given its stream-specific option value
55
+ #
56
+ # Prioritizes the stream-specific value if set, otherwise falls back to
57
+ # `encoding`, and finally defaults to `DEFAULT_ENCODING`. The result is
58
+ # canonicalized with {#canonical_encoding}.
59
+ #
60
+ # @param stream_encoding [Encoding, String, Symbol, nil] the value of
61
+ # `stdout_encoding` or `stderr_encoding`
62
+ #
63
+ # @return [Encoding, nil] nil if the value names an unknown encoding
64
+ #
65
+ # @api private
66
+ #
67
+ def effective_encoding(stream_encoding)
68
+ canonical_encoding(stream_encoding || encoding || DEFAULT_ENCODING)
69
+ end
70
+
58
71
  # The options allowed for objects of this class
59
72
  # @return [Array<OptionDefinition>]
60
73
  # @api private
@@ -148,70 +161,26 @@ module ProcessExecuter
148
161
  end
149
162
 
150
163
  # Note an error if the encoding option is not valid
151
- # @param key [Symbol] the option key
152
- # @param value [Object] the option value
153
- # @return [Void]
154
- # @api private
155
- def validate_encoding_option(key, value)
156
- return unless valid_encoding_type?(key, value)
157
-
158
- return if value.nil? || value.is_a?(Encoding)
159
-
160
- validate_encoding_symbol(key, value) if value.is_a?(Symbol)
161
-
162
- validate_encoding_string(key, value) if value.is_a?(String)
163
- end
164
-
165
- # False if the value is not a valid encoding type, true otherwise
166
- #
167
- # @param key [Symbol] the option key
168
164
  #
169
- # @param value [Object] the option value
170
- #
171
- # @return [Boolean]
172
- #
173
- # @api private
174
- #
175
- def valid_encoding_type?(key, value)
176
- return true if value.nil? || value.is_a?(Encoding) || value.is_a?(Symbol) || value.is_a?(String)
177
-
178
- errors << "#{key} must be an Encoding object, String, Symbol (:binary, :default_external), " \
179
- "or nil, but was #{value.inspect}"
180
-
181
- false
182
- end
183
-
184
- # Note an error if the encoding symbol is not valid
165
+ # `nil`, an Encoding object, `:binary`, and `:default_external` are
166
+ # valid as given. A String is valid if {#canonical_encoding} recognizes
167
+ # it as an encoding name. Any other value is invalid.
185
168
  #
186
169
  # @param key [Symbol] the option key
187
- #
188
- # @param value [Symbol] the option value
189
- #
170
+ # @param value [Object] the option value
190
171
  # @return [Void]
191
- #
192
172
  # @api private
193
- #
194
- def validate_encoding_symbol(key, value)
195
- return if %i[binary default_external].include?(value)
196
-
197
- errors << "#{key} when given as a symbol must be :binary or :default_external, " \
198
- "but was #{value.inspect}"
199
- end
200
-
201
- # Note an error if the encoding string is not valid
202
- #
203
- # @param key [Symbol] the option key
204
- #
205
- # @param value [String] the option value
206
- #
207
- # @return [void]
208
- #
209
- # @api private
210
- #
211
- def validate_encoding_string(key, value)
212
- Encoding.find(value)
213
- rescue ::ArgumentError
214
- errors << "#{key} specifies an unknown encoding name: #{value.inspect}"
173
+ def validate_encoding_option(key, value)
174
+ case value
175
+ when nil, Encoding, :binary, :default_external then nil
176
+ when Symbol
177
+ errors << "#{key} when given as a symbol must be :binary or :default_external, but was #{value.inspect}"
178
+ when String
179
+ errors << "#{key} specifies an unknown encoding name: #{value.inspect}" if canonical_encoding(value).nil?
180
+ else
181
+ errors << "#{key} must be an Encoding object, String, Symbol (:binary, :default_external), " \
182
+ "or nil, but was #{value.inspect}"
183
+ end
215
184
  end
216
185
  end
217
186
  end
@@ -3,5 +3,5 @@
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
5
  # @return [String]
6
- VERSION = '4.1.0'
6
+ VERSION = '4.1.1'
7
7
  end
@@ -4,7 +4,6 @@
4
4
  ".": {
5
5
  "release-type": "ruby",
6
6
  "package-name": "process_executer",
7
- "release-as": "4.1.0",
8
7
  "changelog-path": "CHANGELOG.md",
9
8
  "version-file": "lib/process_executer/version.rb",
10
9
  "bump-minor-pre-major": true,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_executer
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Couball
@@ -295,8 +295,8 @@ metadata:
295
295
  allowed_push_host: https://rubygems.org
296
296
  homepage_uri: https://github.com/main-branch/process_executer
297
297
  source_code_uri: https://github.com/main-branch/process_executer
298
- documentation_uri: https://rubydoc.info/gems/process_executer/4.1.0
299
- changelog_uri: https://rubydoc.info/gems/process_executer/4.1.0/file/CHANGELOG.md
298
+ documentation_uri: https://rubydoc.info/gems/process_executer/4.1.1
299
+ changelog_uri: https://rubydoc.info/gems/process_executer/4.1.1/file/CHANGELOG.md
300
300
  rubygems_mfa_required: 'true'
301
301
  rdoc_options: []
302
302
  require_paths: