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.
- checksums.yaml +4 -4
- data/.editorconfig +10 -0
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +13 -0
- data/.vscode/extensions.json +3 -0
- data/CHANGELOG.md +56 -0
- data/README.md +20 -0
- data/Rakefile +3 -0
- data/lib/process_executer/commands/run.rb +163 -9
- data/lib/process_executer/commands/run_with_capture.rb +89 -35
- data/lib/process_executer/commands/spawn_with_timeout.rb +215 -16
- data/lib/process_executer/destinations/monitored_pipe.rb +7 -4
- data/lib/process_executer/errors.rb +2 -2
- data/lib/process_executer/monitored_pipe.rb +385 -47
- data/lib/process_executer/options/base.rb +48 -7
- data/lib/process_executer/options/run_with_capture_options.rb +92 -61
- data/lib/process_executer/options/spawn_options.rb +13 -0
- data/lib/process_executer/result.rb +13 -2
- data/lib/process_executer/version.rb +1 -1
- data/lib/process_executer.rb +34 -2
- data/process_spawn_test/README.md +38 -1
- data/process_spawn_test/spec/spec_helper.rb +18 -0
- data/process_spawn_test/spec/test_spec.rb +21 -0
- metadata +6 -4
|
@@ -48,7 +48,8 @@ module ProcessExecuter
|
|
|
48
48
|
#
|
|
49
49
|
def call
|
|
50
50
|
begin
|
|
51
|
-
@
|
|
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
|
-
#
|
|
146
|
-
#
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
142
|
+
# Raised when the wait for the command is cut short by `timeout_after` elapsing
|
|
143
143
|
#
|
|
144
144
|
# @example
|
|
145
145
|
# begin
|