class ProcessExecuter::Commands::SpawnWithTimeout
Spawns a subprocess, waits until it completes, and returns the result
Wraps ‘Process.spawn` to provide the core functionality for {ProcessExecuter.spawn_with_timeout}.
It accepts all [Process.spawn execution options](docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options) plus the additional option ‘timeout_after`.
@api private
Attributes
The command to be run in the subprocess @see Process.spawn @example
spawn.command #=> ['echo', 'hello']
@return [Array<String>]
The elapsed time in seconds that the command ran
@example
spawn.elapsed_time #=> 1.234
@return [Numeric]
The options that were used to spawn the process @example
spawn.options #=> ProcessExecuter::Options::SpawnWithTimeoutOptions
@return [ProcessExecuter::Options::SpawnWithTimeoutOptions]
The process ID of the spawned subprocess
@example
spawn.pid #=> 12345
@return [Integer]
The result of the completed subprocess
@example
spawn.result #=> ProcessExecuter::Result
@return [ProcessExecuter::Result]
The status returned by Process.wait2
nil when the timeout was delivered after the wait had already reaped the subprocess: the status was lost to the raise and {#timed_out?} is true.
@example
spawn.status #=> #<Process::Status: pid 12345 exit 0>
@return [Process::Status, nil]
Whether the process timed out
@example
spawn.timed_out? #=> true
@return [Boolean]
Whether the process timed out
@example
spawn.timed_out? #=> true
@return [Boolean]
Public Class Methods
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 30 def initialize(command, options) @command = command @options = options end
Create a new SpawnWithTimeout instance
@example
options = ProcessExecuter::Options::SpawnWithTimeoutOptions.new(timeout_after: 5) result = ProcessExecuter::Commands::SpawnWithTimeout.new('echo hello', options).call result.success? # => true result.exitstatus # => 0
@param command [Array<String>] The command to run in the subprocess @param options [ProcessExecuter::Options::SpawnWithTimeoutOptions] The options to use when spawning the process
Public Instance Methods
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 49 def call begin @effective_spawn_options = spawn_options @pid = Process.spawn(*command, **effective_spawn_options) rescue StandardError => e raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}" end wait_for_process end
Run a command and return the result
@example
options = ProcessExecuter::Options::SpawnWithTimeoutOptions.new(timeout_after: 5) result = ProcessExecuter::Commands::SpawnWithTimeout.new('echo hello', options).call result.success? # => true result.exitstatus # => 0 result.timed_out? # => false
@raise [ProcessExecuter::SpawnError] ‘Process.spawn` raised an error before the
command was run
@return [ProcessExecuter::Result] The result of the completed subprocess
Private Instance Methods
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 208 def create_result ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:) end
Create a result object that includes the status, command, and other details
@return [ProcessExecuter::Result] The result of the command
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 346 def isolated_in_new_process_group? !process_group_options.empty? && process_group_leader? end
Whether this class isolated the subprocess into its own process group
True when {#process_group_options} – the single source of truth for the isolation decision – added a process group option and the subprocess actually became a new process group leader ({#process_group_leader?} over the captured options). The leader check matters only when a subclass’s {#spawn_options} override removed or overrode the added option: then no isolation happened and the abandoned-wait cleanup must leave the subprocess alone. False when the subprocess’s process group (if any) came from a ‘pgroup`/`new_pgroup` option the caller supplied.
@return [Boolean]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 269 def kill_and_reap_abandoned_subprocess return unless isolated_in_new_process_group? kill_subprocess Process.wait2(pid) rescue Exception # rubocop:disable Lint/RescueException # the subprocess may already be dead and reaped; the wait's exception # is what must propagate end
Kill and reap the subprocess when its wait was abandoned by an exception
Only applies to a subprocess this class isolated into its own process group: such a subprocess no longer receives terminal-generated signals (Ctrl-C sends ‘SIGINT` to the caller’s foreground group, not to the new group), so an exception that abandons the wait would otherwise leave it and its descendants running unsupervised and unreaped. A subprocess whose process group came from the caller’s own options keeps its pre-existing signal semantics and is left alone.
Rescues ‘Exception` (not just `StandardError`) so that a second async exception delivered during this best-effort cleanup cannot replace the exception already being re-raised by the caller.
@return [void]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 354 def kill_process_group Process.kill('KILL', -pid) true rescue StandardError false end
Send SIGKILL to the subprocess’s process group
@return [Boolean] true if the signal was sent, false if doing so raised an error
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 309 def kill_subprocess return if process_group_leader? && kill_process_group Process.kill('KILL', pid) rescue Errno::ESRCH # the subprocess already exited and was reaped between the interrupted # wait and the kill; there is nothing left to kill end
Forcibly terminate the timed out subprocess and (if possible) its descendants
When the subprocess was spawned as the leader of its own process group, the whole group is killed so that descendants that would otherwise survive the timeout (and keep any inherited redirection file descriptors open) are terminated too, falling back to killing the direct child if the group kill fails. A group signal reaches only the processes still in that group: a descendant that started its own session or joined another process group (a daemon, for example) is not killed. Otherwise the subprocess is in a process group this object did not create, so only the direct child is killed, matching the pre-process-group behavior.
Killing a process group is only possible on POSIX platforms. On Windows, ‘Process.kill` cannot signal a process group (a negative pid raises an error), so the group kill always falls back to the direct child and descendants may survive the timeout; the bounded {MonitoredPipe#close} keeps such descendants from blocking {ProcessExecuter.run} indefinitely.
A subprocess that already exited and was reaped before the signal is sent (the timeout racing the wait) leaves nothing to kill; that is not an error. In that same microsecond window the freed pid could in principle be recycled to an unrelated process, a hazard inherent to signaling by pid: Ruby exposes no race-free process handle (such as Linux’s pidfd) that would eliminate it, and reuse would require the OS to cycle through its entire pid space within the window.
@return [void]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 328 def process_group_leader? [true, 0].include?(effective_spawn_options[:pgroup]) || effective_spawn_options[:new_pgroup] == true end
Whether the spawn options made the subprocess a new process group leader
True when the process group option – added by {#process_group_options} or given by the caller – asks for a new process group with the subprocess as its leader (‘pgroup: true`, `pgroup: 0`, or `new_pgroup: true`). False when there is no process group option or when `pgroup` places the subprocess in an existing process group.
@return [Boolean]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 177 def process_group_options return {} unless options.timeout_after&.positive? return {} unless options.pgroup == :not_set && options.new_pgroup == :not_set windows? ? { new_pgroup: true } : { pgroup: true } end
Spawn options that place the subprocess into its own process group
When ‘timeout_after` is set to a value that can fire (`nil` and `0` mean “no timeout”), the subprocess is made the leader of a new process group so that a timeout can kill the whole group – including descendants that inherited the redirections – instead of just the direct child. Empty when no timeout can fire or when the caller gave a `pgroup`/`new_pgroup` option themselves (their setting is honored).
This method never reflects a subclass’s {#spawn_options} override, so {#isolated_in_new_process_group?} never counts an option a subclass contributes as isolation by this class – though such an option can still make the subprocess a process group leader (see {#process_group_leader?}) – and a subclass that removes the option added here prevents the isolation (and its cleanup) altogether.
A new process group is a background group for any terminal the subprocess inherits, so an interactive subprocess that reads the terminal is stopped by ‘SIGTTIN` and then killed when the timeout fires – which is the bound `timeout_after` promises. A caller who needs an interactive subprocess to stay in the foreground process group can pass their own `pgroup` option.
Deterministic: the result depends only on {#options} – not mutated during {#call} – and the platform, so the kill path’s {#isolated_in_new_process_group?} re-read agrees with the value that was merged into the spawn options.
@return [Hash]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 134 def spawn_options = options.spawn_options.merge(process_group_options) # The spawn options that were passed to Process.spawn # # Captured once by {#call} -- after any {#spawn_options} additions a # subclass contributed -- so the kill path inspects the options actually # used instead of recomputing the merge. nil until {#call} spawns the # subprocess; the kill path only runs after that. # # @return [Hash, nil] # attr_reader :effective_spawn_options # Spawn options that place the subprocess into its own process group # # When `timeout_after` is set to a value that can fire (`nil` and `0` # mean "no timeout"), the subprocess is made the leader of a new process # group so that a timeout can kill the whole group -- including # descendants that inherited the redirections -- instead of just the # direct child. Empty when no timeout can fire or when the caller gave a # `pgroup`/`new_pgroup` option themselves (their setting is honored). # # This method never reflects a subclass's {#spawn_options} override, so # {#isolated_in_new_process_group?} never counts an option a subclass # contributes as isolation by this class -- though such an option can # still make the subprocess a process group leader (see # {#process_group_leader?}) -- and a subclass that removes the option # added here prevents the isolation (and its cleanup) altogether. # # A new process group is a background group for any terminal the # subprocess inherits, so an interactive subprocess that reads the # terminal is stopped by `SIGTTIN` and then killed when the timeout # fires -- which is the bound `timeout_after` promises. A caller who # needs an interactive subprocess to stay in the foreground process # group can pass their own `pgroup` option. # # Deterministic: the result depends only on {#options} -- not mutated # during {#call} -- and the platform, so the kill path's # {#isolated_in_new_process_group?} re-read agrees with the value that # was merged into the spawn options. # # @return [Hash] # def process_group_options return {} unless options.timeout_after&.positive? return {} unless options.pgroup == :not_set && options.new_pgroup == :not_set windows? ? { new_pgroup: true } : { pgroup: true } end # Whether the current platform is Windows # # @return [Boolean] # def windows? = Gem.win_platform? # Wait for process to terminate # # If a `:timeout_after` is specified in options, terminate the process after the # specified number of seconds. # # @return [ProcessExecuter::Result] The result of the completed subprocess # def wait_for_process start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @status, @timed_out = wait_for_process_raw @elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time @result = create_result end # Create a result object that includes the status, command, and other details # # @return [ProcessExecuter::Result] The result of the command # def create_result ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:) end # Wait for a process to terminate returning the status and timed out flag # # An exception other than the timeout (an `Interrupt` from Ctrl-C, for # example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then # cleans up a subprocess this class isolated into its own process group # before the exception propagates. # # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing # the process status (nil when the timeout raced the wait and the status was lost, # see {#wait_with_timeout}) and a boolean indicating whether the process timed out def wait_for_process_raw wait_with_timeout rescue Exception # rubocop:disable Lint/RescueException kill_and_reap_abandoned_subprocess raise end # Wait for the process, killing it when `timeout_after` expires first # # The timeout can be delivered after the timed wait has already reaped # the subprocess but before it returns. In that race the subprocess's # status was lost to the raise, so the status is nil and the timed out # flag is still set. # # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing # the process status (nil when the timeout raced the wait and the status was lost) # and a boolean indicating whether the process timed out def wait_with_timeout process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last } [process_status, false] rescue Timeout::Error kill_subprocess begin [Process.wait2(pid).last, true] rescue Errno::ECHILD # the interrupted wait already reaped the subprocess; its status was # lost to the raise [nil, true] end end # Kill and reap the subprocess when its wait was abandoned by an exception # # Only applies to a subprocess this class isolated into its own process # group: such a subprocess no longer receives terminal-generated signals # (Ctrl-C sends `SIGINT` to the caller's foreground group, not to the # new group), so an exception that abandons the wait would otherwise # leave it and its descendants running unsupervised and unreaped. A # subprocess whose process group came from the caller's own options # keeps its pre-existing signal semantics and is left alone. # # Rescues `Exception` (not just `StandardError`) so that a second async # exception delivered during this best-effort cleanup cannot replace # the exception already being re-raised by the caller. # # @return [void] # def kill_and_reap_abandoned_subprocess return unless isolated_in_new_process_group? kill_subprocess Process.wait2(pid) rescue Exception # rubocop:disable Lint/RescueException # the subprocess may already be dead and reaped; the wait's exception # is what must propagate end # Forcibly terminate the timed out subprocess and (if possible) its descendants # # When the subprocess was spawned as the leader of its own process # group, the whole group is killed so that descendants that would # otherwise survive the timeout (and keep any inherited redirection # file descriptors open) are terminated too, falling back to killing # the direct child if the group kill fails. A group signal reaches only # the processes still in that group: a descendant that started its own # session or joined another process group (a daemon, for example) is # not killed. Otherwise the subprocess is in a process group this # object did not create, so only the direct child is killed, matching # the pre-process-group behavior. # # Killing a process group is only possible on POSIX platforms. On # Windows, `Process.kill` cannot signal a process group (a negative pid # raises an error), so the group kill always falls back to the direct # child and descendants may survive the timeout; the bounded # {MonitoredPipe#close} keeps such descendants from blocking # {ProcessExecuter.run} indefinitely. # # A subprocess that already exited and was reaped before the signal is # sent (the timeout racing the wait) leaves nothing to kill; that is not # an error. In that same microsecond window the freed pid could in # principle be recycled to an unrelated process, a hazard inherent to # signaling by pid: Ruby exposes no race-free process handle (such as # Linux's pidfd) that would eliminate it, and reuse would require the OS # to cycle through its entire pid space within the window. # # @return [void] # def kill_subprocess return if process_group_leader? && kill_process_group Process.kill('KILL', pid) rescue Errno::ESRCH # the subprocess already exited and was reaped between the interrupted # wait and the kill; there is nothing left to kill end # Whether the spawn options made the subprocess a new process group leader # # True when the process group option -- added by {#process_group_options} # or given by the caller -- asks for a new process group with the # subprocess as its leader (`pgroup: true`, `pgroup: 0`, or # `new_pgroup: true`). False when there is no process group option or # when `pgroup` places the subprocess in an existing process group. # # @return [Boolean] # def process_group_leader? [true, 0].include?(effective_spawn_options[:pgroup]) || effective_spawn_options[:new_pgroup] == true end # Whether this class isolated the subprocess into its own process group # # True when {#process_group_options} -- the single source of truth for # the isolation decision -- added a process group option and the # subprocess actually became a new process group leader # ({#process_group_leader?} over the captured options). The leader # check matters only when a subclass's {#spawn_options} override # removed or overrode the added option: then no isolation happened and # the abandoned-wait cleanup must leave the subprocess alone. False # when the subprocess's process group (if any) came from a # `pgroup`/`new_pgroup` option the caller supplied. # # @return [Boolean] # def isolated_in_new_process_group? !process_group_options.empty? && process_group_leader? end # Send SIGKILL to the subprocess's process group # # @return [Boolean] true if the signal was sent, false if doing so raised an error # def kill_process_group Process.kill('KILL', -pid) true rescue StandardError false end end end
The options to pass to Process.spawn
Subclasses may override this method to combine internal redirections with the user’s options without modifying the options object the caller gave.
@return [Hash]
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 197 def wait_for_process start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @status, @timed_out = wait_for_process_raw @elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time @result = create_result end
Wait for process to terminate
If a ‘:timeout_after` is specified in options, terminate the process after the specified number of seconds.
@return [ProcessExecuter::Result] The result of the completed subprocess
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 222 def wait_for_process_raw wait_with_timeout rescue Exception # rubocop:disable Lint/RescueException kill_and_reap_abandoned_subprocess raise end
Wait for a process to terminate returning the status and timed out flag
An exception other than the timeout (an ‘Interrupt` from Ctrl-C, for example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then cleans up a subprocess this class isolated into its own process group before the exception propagates.
@return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
the process status (nil when the timeout raced the wait and the status was lost,
see {#wait_with_timeout}) and a boolean indicating whether the process timed out
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 239 def wait_with_timeout process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last } [process_status, false] rescue Timeout::Error kill_subprocess begin [Process.wait2(pid).last, true] rescue Errno::ECHILD # the interrupted wait already reaped the subprocess; its status was # lost to the raise [nil, true] end end
Wait for the process, killing it when ‘timeout_after` expires first
The timeout can be delivered after the timed wait has already reaped the subprocess but before it returns. In that race the subprocess’s status was lost to the raise, so the status is nil and the timed out flag is still set.
@return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
the process status (nil when the timeout raced the wait and the status was lost) and a boolean indicating whether the process timed out
Source
# File lib/process_executer/commands/spawn_with_timeout.rb, line 188 def windows? = Gem.win_platform? # Wait for process to terminate # # If a `:timeout_after` is specified in options, terminate the process after the # specified number of seconds. # # @return [ProcessExecuter::Result] The result of the completed subprocess # def wait_for_process start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @status, @timed_out = wait_for_process_raw @elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time @result = create_result end # Create a result object that includes the status, command, and other details # # @return [ProcessExecuter::Result] The result of the command # def create_result ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:) end # Wait for a process to terminate returning the status and timed out flag # # An exception other than the timeout (an `Interrupt` from Ctrl-C, for # example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then # cleans up a subprocess this class isolated into its own process group # before the exception propagates. # # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing # the process status (nil when the timeout raced the wait and the status was lost, # see {#wait_with_timeout}) and a boolean indicating whether the process timed out def wait_for_process_raw wait_with_timeout rescue Exception # rubocop:disable Lint/RescueException kill_and_reap_abandoned_subprocess raise end # Wait for the process, killing it when `timeout_after` expires first # # The timeout can be delivered after the timed wait has already reaped # the subprocess but before it returns. In that race the subprocess's # status was lost to the raise, so the status is nil and the timed out # flag is still set. # # @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing # the process status (nil when the timeout raced the wait and the status was lost) # and a boolean indicating whether the process timed out def wait_with_timeout process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last } [process_status, false] rescue Timeout::Error kill_subprocess begin [Process.wait2(pid).last, true] rescue Errno::ECHILD # the interrupted wait already reaped the subprocess; its status was # lost to the raise [nil, true] end end # Kill and reap the subprocess when its wait was abandoned by an exception # # Only applies to a subprocess this class isolated into its own process # group: such a subprocess no longer receives terminal-generated signals # (Ctrl-C sends `SIGINT` to the caller's foreground group, not to the # new group), so an exception that abandons the wait would otherwise # leave it and its descendants running unsupervised and unreaped. A # subprocess whose process group came from the caller's own options # keeps its pre-existing signal semantics and is left alone. # # Rescues `Exception` (not just `StandardError`) so that a second async # exception delivered during this best-effort cleanup cannot replace # the exception already being re-raised by the caller. # # @return [void] # def kill_and_reap_abandoned_subprocess return unless isolated_in_new_process_group? kill_subprocess Process.wait2(pid) rescue Exception # rubocop:disable Lint/RescueException # the subprocess may already be dead and reaped; the wait's exception # is what must propagate end # Forcibly terminate the timed out subprocess and (if possible) its descendants # # When the subprocess was spawned as the leader of its own process # group, the whole group is killed so that descendants that would # otherwise survive the timeout (and keep any inherited redirection # file descriptors open) are terminated too, falling back to killing # the direct child if the group kill fails. A group signal reaches only # the processes still in that group: a descendant that started its own # session or joined another process group (a daemon, for example) is # not killed. Otherwise the subprocess is in a process group this # object did not create, so only the direct child is killed, matching # the pre-process-group behavior. # # Killing a process group is only possible on POSIX platforms. On # Windows, `Process.kill` cannot signal a process group (a negative pid # raises an error), so the group kill always falls back to the direct # child and descendants may survive the timeout; the bounded # {MonitoredPipe#close} keeps such descendants from blocking # {ProcessExecuter.run} indefinitely. # # A subprocess that already exited and was reaped before the signal is # sent (the timeout racing the wait) leaves nothing to kill; that is not # an error. In that same microsecond window the freed pid could in # principle be recycled to an unrelated process, a hazard inherent to # signaling by pid: Ruby exposes no race-free process handle (such as # Linux's pidfd) that would eliminate it, and reuse would require the OS # to cycle through its entire pid space within the window. # # @return [void] # def kill_subprocess return if process_group_leader? && kill_process_group Process.kill('KILL', pid) rescue Errno::ESRCH # the subprocess already exited and was reaped between the interrupted # wait and the kill; there is nothing left to kill end # Whether the spawn options made the subprocess a new process group leader # # True when the process group option -- added by {#process_group_options} # or given by the caller -- asks for a new process group with the # subprocess as its leader (`pgroup: true`, `pgroup: 0`, or # `new_pgroup: true`). False when there is no process group option or # when `pgroup` places the subprocess in an existing process group. # # @return [Boolean] # def process_group_leader? [true, 0].include?(effective_spawn_options[:pgroup]) || effective_spawn_options[:new_pgroup] == true end # Whether this class isolated the subprocess into its own process group # # True when {#process_group_options} -- the single source of truth for # the isolation decision -- added a process group option and the # subprocess actually became a new process group leader # ({#process_group_leader?} over the captured options). The leader # check matters only when a subclass's {#spawn_options} override # removed or overrode the added option: then no isolation happened and # the abandoned-wait cleanup must leave the subprocess alone. False # when the subprocess's process group (if any) came from a # `pgroup`/`new_pgroup` option the caller supplied. # # @return [Boolean] # def isolated_in_new_process_group? !process_group_options.empty? && process_group_leader? end # Send SIGKILL to the subprocess's process group # # @return [Boolean] true if the signal was sent, false if doing so raised an error # def kill_process_group Process.kill('KILL', -pid) true rescue StandardError false end end
Whether the current platform is Windows
@return [Boolean]