process_executer 4.0.4 → 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.
@@ -48,7 +48,8 @@ module ProcessExecuter
48
48
  #
49
49
  def call
50
50
  begin
51
- @pid = Process.spawn(*command, **options.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
@@ -80,10 +81,14 @@ module ProcessExecuter
80
81
 
81
82
  # The status returned by Process.wait2
82
83
  #
84
+ # nil when the timeout was delivered after the wait had already reaped
85
+ # the subprocess: the status was lost to the raise and {#timed_out?} is
86
+ # true.
87
+ #
83
88
  # @example
84
89
  # spawn.status #=> #<Process::Status: pid 12345 exit 0>
85
90
  #
86
- # @return [Process::Status]
91
+ # @return [Process::Status, nil]
87
92
  #
88
93
  attr_reader :status
89
94
 
@@ -118,6 +123,70 @@ module ProcessExecuter
118
123
 
119
124
  private
120
125
 
126
+ # The options to pass to Process.spawn
127
+ #
128
+ # Subclasses may override this method to combine internal redirections
129
+ # with the user's options without modifying the options object the
130
+ # caller gave.
131
+ #
132
+ # @return [Hash]
133
+ #
134
+ def spawn_options = options.spawn_options.merge(process_group_options)
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
+
147
+ # Spawn options that place the subprocess into its own process group
148
+ #
149
+ # When `timeout_after` is set to a value that can fire (`nil` and `0`
150
+ # mean "no timeout"), the subprocess is made the leader of a new process
151
+ # group so that a timeout can kill the whole group -- including
152
+ # descendants that inherited the redirections -- instead of just the
153
+ # direct child. Empty when no timeout can fire or when the caller gave a
154
+ # `pgroup`/`new_pgroup` option themselves (their setting is honored).
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
+ #
163
+ # A new process group is a background group for any terminal the
164
+ # subprocess inherits, so an interactive subprocess that reads the
165
+ # terminal is stopped by `SIGTTIN` and then killed when the timeout
166
+ # fires -- which is the bound `timeout_after` promises. A caller who
167
+ # needs an interactive subprocess to stay in the foreground process
168
+ # group can pass their own `pgroup` option.
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
+ #
175
+ # @return [Hash]
176
+ #
177
+ def process_group_options
178
+ return {} unless options.timeout_after&.positive?
179
+ return {} unless options.pgroup == :not_set && options.new_pgroup == :not_set
180
+
181
+ windows? ? { new_pgroup: true } : { pgroup: true }
182
+ end
183
+
184
+ # Whether the current platform is Windows
185
+ #
186
+ # @return [Boolean]
187
+ #
188
+ def windows? = Gem.win_platform?
189
+
121
190
  # Wait for process to terminate
122
191
  #
123
192
  # If a `:timeout_after` is specified in options, terminate the process after the
@@ -142,21 +211,151 @@ module ProcessExecuter
142
211
 
143
212
  # Wait for a process to terminate returning the status and timed out flag
144
213
  #
145
- # @return [Array<Process::Status, Boolean>] an array containing the process status and a boolean
146
- # indicating whether the process timed out
214
+ # An exception other than the timeout (an `Interrupt` from Ctrl-C, for
215
+ # example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then
216
+ # cleans up a subprocess this class isolated into its own process group
217
+ # before the exception propagates.
218
+ #
219
+ # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
220
+ # the process status (nil when the timeout raced the wait and the status was lost,
221
+ # see {#wait_with_timeout}) and a boolean indicating whether the process timed out
147
222
  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]
223
+ wait_with_timeout
224
+ rescue Exception # rubocop:disable Lint/RescueException
225
+ kill_and_reap_abandoned_subprocess
226
+ raise
227
+ end
228
+
229
+ # Wait for the process, killing it when `timeout_after` expires first
230
+ #
231
+ # The timeout can be delivered after the timed wait has already reaped
232
+ # the subprocess but before it returns. In that race the subprocess's
233
+ # status was lost to the raise, so the status is nil and the timed out
234
+ # flag is still set.
235
+ #
236
+ # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
237
+ # the process status (nil when the timeout raced the wait and the status was lost)
238
+ # and a boolean indicating whether the process timed out
239
+ def wait_with_timeout
240
+ process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last }
241
+ [process_status, false]
242
+ rescue Timeout::Error
243
+ kill_subprocess
244
+ begin
245
+ [Process.wait2(pid).last, true]
246
+ rescue Errno::ECHILD
247
+ # the interrupted wait already reaped the subprocess; its status was
248
+ # lost to the raise
249
+ [nil, true]
250
+ end
251
+ end
252
+
253
+ # Kill and reap the subprocess when its wait was abandoned by an exception
254
+ #
255
+ # Only applies to a subprocess this class isolated into its own process
256
+ # group: such a subprocess no longer receives terminal-generated signals
257
+ # (Ctrl-C sends `SIGINT` to the caller's foreground group, not to the
258
+ # new group), so an exception that abandons the wait would otherwise
259
+ # leave it and its descendants running unsupervised and unreaped. A
260
+ # subprocess whose process group came from the caller's own options
261
+ # keeps its pre-existing signal semantics and is left alone.
262
+ #
263
+ # Rescues `Exception` (not just `StandardError`) so that a second async
264
+ # exception delivered during this best-effort cleanup cannot replace
265
+ # the exception already being re-raised by the caller.
266
+ #
267
+ # @return [void]
268
+ #
269
+ def kill_and_reap_abandoned_subprocess
270
+ return unless isolated_in_new_process_group?
271
+
272
+ kill_subprocess
273
+ Process.wait2(pid)
274
+ rescue Exception # rubocop:disable Lint/RescueException
275
+ # the subprocess may already be dead and reaped; the wait's exception
276
+ # is what must propagate
277
+ end
278
+
279
+ # Forcibly terminate the timed out subprocess and (if possible) its descendants
280
+ #
281
+ # When the subprocess was spawned as the leader of its own process
282
+ # group, the whole group is killed so that descendants that would
283
+ # otherwise survive the timeout (and keep any inherited redirection
284
+ # file descriptors open) are terminated too, falling back to killing
285
+ # the direct child if the group kill fails. A group signal reaches only
286
+ # the processes still in that group: a descendant that started its own
287
+ # session or joined another process group (a daemon, for example) is
288
+ # not killed. Otherwise the subprocess is in a process group this
289
+ # object did not create, so only the direct child is killed, matching
290
+ # the pre-process-group behavior.
291
+ #
292
+ # Killing a process group is only possible on POSIX platforms. On
293
+ # Windows, `Process.kill` cannot signal a process group (a negative pid
294
+ # raises an error), so the group kill always falls back to the direct
295
+ # child and descendants may survive the timeout; the bounded
296
+ # {MonitoredPipe#close} keeps such descendants from blocking
297
+ # {ProcessExecuter.run} indefinitely.
298
+ #
299
+ # A subprocess that already exited and was reaped before the signal is
300
+ # sent (the timeout racing the wait) leaves nothing to kill; that is not
301
+ # an error. In that same microsecond window the freed pid could in
302
+ # principle be recycled to an unrelated process, a hazard inherent to
303
+ # signaling by pid: Ruby exposes no race-free process handle (such as
304
+ # Linux's pidfd) that would eliminate it, and reuse would require the OS
305
+ # to cycle through its entire pid space within the window.
306
+ #
307
+ # @return [void]
308
+ #
309
+ def kill_subprocess
310
+ return if process_group_leader? && kill_process_group
311
+
312
+ Process.kill('KILL', pid)
313
+ rescue Errno::ESRCH
314
+ # the subprocess already exited and was reaped between the interrupted
315
+ # wait and the kill; there is nothing left to kill
316
+ end
317
+
318
+ # Whether the spawn options made the subprocess a new process group leader
319
+ #
320
+ # True when the process group option -- added by {#process_group_options}
321
+ # or given by the caller -- asks for a new process group with the
322
+ # subprocess as its leader (`pgroup: true`, `pgroup: 0`, or
323
+ # `new_pgroup: true`). False when there is no process group option or
324
+ # when `pgroup` places the subprocess in an existing process group.
325
+ #
326
+ # @return [Boolean]
327
+ #
328
+ def process_group_leader?
329
+ [true, 0].include?(effective_spawn_options[:pgroup]) || effective_spawn_options[:new_pgroup] == true
330
+ end
331
+
332
+ # Whether this class isolated the subprocess into its own process group
333
+ #
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.
343
+ #
344
+ # @return [Boolean]
345
+ #
346
+ def isolated_in_new_process_group?
347
+ !process_group_options.empty? && process_group_leader?
348
+ end
349
+
350
+ # Send SIGKILL to the subprocess's process group
351
+ #
352
+ # @return [Boolean] true if the signal was sent, false if doing so raised an error
353
+ #
354
+ def kill_process_group
355
+ Process.kill('KILL', -pid)
356
+ true
357
+ rescue StandardError
358
+ false
160
359
  end
161
360
  end
162
361
  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
  #
@@ -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