1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
|
# frozen_string_literal: true
require_relative '../errors'
module ProcessExecuter
module Commands
# 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](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options)
# plus the additional option `timeout_after`.
#
# @api private
#
class SpawnWithTimeout
# 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
#
def initialize(command, options)
@command = command
@options = options
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
#
def call
begin
@pid = Process.spawn(*command, **options.spawn_options)
rescue StandardError => e
raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}"
end
wait_for_process
end
# The command to be run in the subprocess
# @see Process.spawn
# @example
# spawn.command #=> ['echo', 'hello']
# @return [Array<String>]
attr_reader :command
# The options that were used to spawn the process
# @example
# spawn.options #=> ProcessExecuter::Options::SpawnWithTimeoutOptions
# @return [ProcessExecuter::Options::SpawnWithTimeoutOptions]
attr_reader :options
# The process ID of the spawned subprocess
#
# @example
# spawn.pid #=> 12345
#
# @return [Integer]
#
attr_reader :pid
# The status returned by Process.wait2
#
# @example
# spawn.status #=> #<Process::Status: pid 12345 exit 0>
#
# @return [Process::Status]
#
attr_reader :status
# Whether the process timed out
#
# @example
# spawn.timed_out? #=> true
#
# @return [Boolean]
#
attr_reader :timed_out
alias timed_out? timed_out
# The elapsed time in seconds that the command ran
#
# @example
# spawn.elapsed_time #=> 1.234
#
# @return [Numeric]
#
attr_reader :elapsed_time
# The result of the completed subprocess
#
# @example
# spawn.result #=> ProcessExecuter::Result
#
# @return [ProcessExecuter::Result]
#
attr_reader :result
private
# 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
#
# @return [Array<Process::Status, Boolean>] an array containing the process status and a boolean
# indicating whether the process timed out
def wait_for_process_raw
timed_out = false
process_status =
begin
Timeout.timeout(options.timeout_after) { Process.wait2(pid).last }
rescue Timeout::Error
Process.kill('KILL', pid)
timed_out = true
Process.wait2(pid).last
end
[process_status, timed_out]
end
end
end
end
|