Module: Dependabot::CommandHelpers

Extended by:
T::Sig
Defined in:
lib/dependabot/command_helpers.rb

Defined Under Namespace

Modules: TIMEOUTS Classes: ProcessStatus

Constant Summary collapse

OutputObserver =
T.type_alias do
  T.nilable(T.proc.params(data: String).returns(T::Hash[Symbol, T.untyped]))
end

Class Method Summary collapse

Class Method Details

.capture3_with_timeout(env_cmd, stdin_data: nil, stderr_to_stdout: false, timeout: TIMEOUTS::DEFAULT, output_observer: nil) ⇒ Object



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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/dependabot/command_helpers.rb', line 82

def self.capture3_with_timeout(
  env_cmd,
  stdin_data: nil,
  stderr_to_stdout: false,
  timeout: TIMEOUTS::DEFAULT,
  output_observer: nil
)
  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

          # Observe the output if an observer is provided.
          # This allows for custom handling of process output, including early termination.
          observation = output_observer&.call(data)

          if observation&.dig(:gracefully_stop)
            message = observation[:reason] || "Terminated by output_observer"
            # If the observer indicates a graceful stop, terminate the process
            # by adjusting the remaining timeout 5 seconds
            timeout = [timeout, ((Time.now - last_output_time) + 5).to_i].min
            Dependabot.logger.warn("Terminating process due to observer signal: #{message}")
          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

.escape_command(command) ⇒ Object



239
240
241
242
# File 'lib/dependabot/command_helpers.rb', line 239

def self.escape_command(command)
  command_parts = command.split.map(&:strip).reject(&:empty?)
  Shellwords.join(command_parts)
end

.process_alive?(pid) ⇒ Boolean

Returns:

  • (Boolean)


223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/dependabot/command_helpers.rb', line 223

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

.terminate_process(pid) ⇒ Object



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/dependabot/command_helpers.rb', line 199

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