module Dependabot::CommandHelpers
def self.capture3_with_timeout(
def self.capture3_with_timeout( env_cmd, stdin_data: nil, stderr_to_stdout: false, timeout: TIMEOUTS::DEFAULT ) stdout = T.let("", String) stderr = T.let("", String) status = T.let(nil, T.nilable(ProcessStatus)) pid = T.let(nil, T.untyped) start_time = Time.now begin T.unsafe(Open3).popen3(*env_cmd) do |stdin, stdout_io, stderr_io, wait_thr| # rubocop:disable Metrics/BlockLength pid = wait_thr.pid Dependabot.logger.info("Started process PID: #{pid} with command: #{env_cmd.join(' ')}") # Write to stdin if input data is provided stdin&.write(stdin_data) if stdin_data stdin&.close stdout_io.sync = true stderr_io.sync = true # Array to monitor both stdout and stderr ios = [stdout_io, stderr_io] last_output_time = Time.now # Track the last time output was received until ios.empty? if timeout.positive? # Calculate remaining timeout dynamically remaining_timeout = timeout - (Time.now - last_output_time) # Raise an error if timeout is exceeded if remaining_timeout <= 0 Dependabot.logger.warn("Process PID: #{pid} timed out after #{timeout}s. Terminating...") terminate_process(pid) status = ProcessStatus.new(wait_thr.value, 124) raise Timeout::Error, "Timed out due to inactivity after #{timeout} seconds" end end # Use IO.select with a dynamically calculated short timeout ready_ios = IO.select(ios, nil, nil, 0) # Process ready IO streams ready_ios&.first&.each do |io| # 1. Read data from the stream io.set_encoding("BINARY") data = io.read_nonblock(1024) # 2. Force encoding to UTF-8 (for proper conversion) data.force_encoding("UTF-8") # 3. Convert to UTF-8 safely, handling invalid/undefined bytes data = data.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?") # Reset the timeout if data is received last_output_time = Time.now unless data.empty? # 4. Append data to the appropriate stream if io == stdout_io stdout += data else stderr += data unless stderr_to_stdout stdout += data if stderr_to_stdout end rescue EOFError # Remove the stream when EOF is reached ios.delete(io) rescue IO::WaitReadable # Continue when IO is not ready yet next end end status = ProcessStatus.new(wait_thr.value) Dependabot.logger.info("Process PID: #{pid} completed with status: #{status}") end rescue Timeout::Error => e Dependabot.logger.error("Process PID: #{pid} failed due to timeout: #{e.message}") terminate_process(pid) # Append timeout message only to stderr without interfering with stdout stderr += "\n#{e.message}" unless stderr_to_stdout stdout += "\n#{e.message}" if stderr_to_stdout rescue Errno::ENOENT => e Dependabot.logger.error("Command failed: #{e.message}") stderr += e.message unless stderr_to_stdout stdout += e.message if stderr_to_stdout end elapsed_time = Time.now - start_time Dependabot.logger.info("Total execution time: #{elapsed_time.round(2)} seconds") [stdout, stderr, status, elapsed_time] end
def self.escape_command(command)
def self.escape_command(command) command_parts = command.split.map(&:strip).reject(&:empty?) Shellwords.join(command_parts) end
def self.process_alive?(pid)
def self.process_alive?(pid) return false if pid.nil? begin Process.kill(0, pid) # Check if the process exists true rescue Errno::ESRCH false rescue Errno::EPERM Dependabot.logger.error("Insufficient permissions to check process: #{pid}") false end end
def self.terminate_process(pid)
def self.terminate_process(pid) return unless pid begin if process_alive?(pid) Process.kill("TERM", pid) # Attempt graceful termination sleep(0.5) # Allow process to terminate end if process_alive?(pid) Process.kill("KILL", pid) # Forcefully kill if still running end rescue Errno::EPERM Dependabot.logger.error("Insufficient permissions to terminate process: #{pid}") ensure begin Process.waitpid(pid) rescue Errno::ESRCH, Errno::ECHILD # Process has already exited end end end