process_executer 4.0.3 → 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.
@@ -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
@@ -25,12 +25,15 @@ module ProcessExecuter
25
25
  destination.write data
26
26
  end
27
27
 
28
- # Closes the pipe if it's open
28
+ # Does nothing: closing a caller-provided pipe is the caller's responsibility
29
+ #
30
+ # The library closes only pipes it creates. A {ProcessExecuter::MonitoredPipe}
31
+ # given as a redirection destination was created by the caller, and the
32
+ # {ProcessExecuter::MonitoredPipe} documentation makes closing it the
33
+ # caller's responsibility.
29
34
  #
30
35
  # @return [void]
31
- def close
32
- destination.close if destination.state == :open
33
- end
36
+ def close; end
34
37
 
35
38
  # Determines if this class can handle the given destination
36
39
  #
@@ -76,7 +76,7 @@ module ProcessExecuter
76
76
  destination_classes =
77
77
  ProcessExecuter::Destinations.constants
78
78
  .map { |const| ProcessExecuter::Destinations.const_get(const) }
79
- .select { |const| const.is_a?(Class) }
79
+ .grep(Class)
80
80
  .reject { |klass| klass == ProcessExecuter::Destinations::DestinationBase }
81
81
 
82
82
  destination_classes.find { |klass| klass.handles?(destination) }
@@ -29,7 +29,7 @@ module ProcessExecuter
29
29
  # | `CommandError` | A subclass of this error is raised when there is a problem executing a command. |
30
30
  # | `FailedError` | Raised when the command exits with a non-zero exit status. |
31
31
  # | `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. |
32
- # | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. |
32
+ # | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the wait for the command is cut short by the configured `timeout_after` elapsing. The command is sent the SIGKILL signal, unless it had already exited in the moment between the timeout firing and the kill. |
33
33
  # | `ProcessIOError` | Raised when an error was encountered reading or writing to the command's subprocess. |
34
34
  # | `SpawnError` | Raised when the process could not execute. Check the `#cause` for the original exception from `Process.spawn`. |
35
35
  #
@@ -139,7 +139,7 @@ module ProcessExecuter
139
139
  #
140
140
  class SignaledError < ProcessExecuter::CommandError; end
141
141
 
142
- # Raised when the command takes longer than the configured timeout_after
142
+ # Raised when the wait for the command is cut short by `timeout_after` elapsing
143
143
  #
144
144
  # @example
145
145
  # begin