Skip to content

Commit 023017b

Browse files
authored
Add a timeout for git commands (#692)
* Implement the new timeout feature Signed-off-by: James Couball <jcouball@yahoo.com>
1 parent 8286ceb commit 023017b

File tree

9 files changed

+257
-25
lines changed

9 files changed

+257
-25
lines changed

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111
[![Build Status](https://github.com/ruby-git/ruby-git/workflows/CI/badge.svg?branch=master)](https://github.com/ruby-git/ruby-git/actions?query=workflow%3ACI)
1212
[![Code Climate](https://codeclimate.com/github/ruby-git/ruby-git.png)](https://codeclimate.com/github/ruby-git/ruby-git)
1313

14+
* [Summary](#summary)
15+
* [v2.0.0 pre-release](#v200-pre-release)
16+
* [Install](#install)
17+
* [Major Objects](#major-objects)
18+
* [Errors Raised By This Gem](#errors-raised-by-this-gem)
19+
* [Specifying And Handling Timeouts](#specifying-and-handling-timeouts)
20+
* [Examples](#examples)
21+
* [Ruby version support policy](#ruby-version-support-policy)
22+
* [License](#license)
23+
24+
## Summary
25+
1426
The [git gem](https://rubygems.org/gems/git) provides an API that can be used to
1527
create, read, and manipulate Git repositories by wrapping system calls to the `git`
1628
command line. The API can be used for working with Git in complex interactions
@@ -140,6 +152,60 @@ rescue Git::TimeoutError => e # Catch the more specific error first!
140152
puts "Git clone took too long and timed out #{e}"
141153
rescue Git::Error => e
142154
puts "Received the following error: #{e}"
155+
```
156+
157+
## Specifying And Handling Timeouts
158+
159+
The timeout feature was added in git gem version `2.0.0`.
160+
161+
A timeout for git operations can be set either globally or for specific method calls
162+
that accept a `:timeout` parameter.
163+
164+
The timeout value must be a real, non-negative `Numeric` value that specifies a
165+
number of seconds a `git` command will be given to complete before being sent a KILL
166+
signal. This library may hang if the `git` command does not terminate after receiving
167+
the KILL signal.
168+
169+
When a command times out, a `Git::TimeoutError` is raised.
170+
171+
If the timeout value is `0` or `nil`, no timeout will be enforced.
172+
173+
If a method accepts a `:timeout` parameter and a receives a non-nil value, it will
174+
override the global timeout value. In this context, a value of `nil` (which is
175+
usually the default) will use the global timeout value and a value of `0` will turn
176+
off timeout enforcement for that method call no matter what the global value is.
177+
178+
To set a global timeout, use the `Git.config` object:
179+
180+
```ruby
181+
Git.config.timeout = nil # a value of nil or 0 means no timeout is enforced
182+
Git.config.timeout = 1.5 # can be any real, non-negative Numeric interpreted as number of seconds
183+
```
184+
185+
The global timeout can be overridden for a specific method if the method accepts a
186+
`:timeout` parameter:
187+
188+
```ruby
189+
repo_url = 'https://github.com/ruby-git/ruby-git.git'
190+
Git.clone(repo_url) # Use the global timeout value
191+
Git.clone(repo_url, timeout: nil) # Also uses the global timeout value
192+
Git.clone(repo_url, timeout: 0) # Do not enforce a timeout
193+
Git.clone(repo_url, timeout: 10.5) # Timeout after 10.5 seconds raising Git::SignaledError
194+
```
195+
196+
If the command takes too long, a `Git::SignaledError` will be raised:
197+
198+
```ruby
199+
begin
200+
Git.clone(repo_url, timeout: 10)
201+
rescue Git::TimeoutError => e
202+
result = e.result
203+
result.class #=> Git::CommandLineResult
204+
result.status #=> #<Process::Status: pid 62173 SIGKILL (signal 9)>
205+
result.status.timeout? #=> true
206+
result.git_cmd # The git command ran as an array of strings
207+
result.stdout # The command's output to stdout until it was terminated
208+
result.stderr # The command's output to stderr until it was terminated
143209
end
144210
```
145211

bin/command_line_test

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ require 'optparse'
3535
class CommandLineParser
3636
def initialize
3737
@option_parser = OptionParser.new
38+
@duration = 0
3839
define_options
3940
end
4041

41-
attr_reader :stdout, :stderr, :exitstatus, :signal
42+
attr_reader :duration, :stdout, :stderr, :exitstatus, :signal
4243

4344
# Parse the command line arguements returning the options
4445
#
@@ -84,7 +85,7 @@ class CommandLineParser
8485
option_parser.separator 'Options:'
8586
%i[
8687
define_help_option define_stdout_option define_stderr_option
87-
define_exitstatus_option define_signal_option
88+
define_exitstatus_option define_signal_option define_duration_option
8889
].each { |m| send(m) }
8990
end
9091

@@ -135,6 +136,15 @@ class CommandLineParser
135136
end
136137
end
137138

139+
# Define the duration option
140+
# @return [void]
141+
# @api private
142+
def define_duration_option
143+
option_parser.on('--duration=0', 'The number of seconds the command should take') do |duration|
144+
@duration = Integer(duration)
145+
end
146+
end
147+
138148
# Define the help option
139149
# @return [void]
140150
# @api private
@@ -176,5 +186,6 @@ options = CommandLineParser.new.parse(*ARGV)
176186

177187
STDOUT.puts options.stdout if options.stdout
178188
STDERR.puts options.stderr if options.stderr
189+
sleep options.duration unless options.duration.zero?
179190
Process.kill(options.signal, Process.pid) if options.signal
180191
exit(options.exitstatus) if options.exitstatus

git.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Gem::Specification.new do |s|
2828
s.requirements = ['git 2.28.0 or greater']
2929

3030
s.add_runtime_dependency 'addressable', '~> 2.8'
31-
s.add_runtime_dependency 'process_executer', '~> 0.7'
31+
s.add_runtime_dependency 'process_executer', '~> 1.1'
3232
s.add_runtime_dependency 'rchardet', '~> 1.8'
3333

3434
s.add_development_dependency 'minitar', '~> 0.9'

lib/git.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
require 'git/base'
88
require 'git/branch'
99
require 'git/branches'
10+
require 'git/command_line_error'
1011
require 'git/command_line_result'
1112
require 'git/command_line'
1213
require 'git/config'
1314
require 'git/diff'
1415
require 'git/encoding_utils'
16+
require 'git/error'
1517
require 'git/escaped_path'
1618
require 'git/failed_error'
1719
require 'git/git_execute_error'
@@ -24,7 +26,6 @@
2426
require 'git/repository'
2527
require 'git/signaled_error'
2628
require 'git/status'
27-
require 'git/signaled_error'
2829
require 'git/stash'
2930
require 'git/stashes'
3031
require 'git/timeout_error'

lib/git/command_line.rb

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,30 @@ def initialize(env, binary_path, global_opts, logger)
166166
# @param merge [Boolean] whether to merge stdout and stderr in the string returned
167167
# @param chdir [String] the directory to run the command in
168168
#
169+
# @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete
170+
#
171+
# If timeout is zero or nil, the command will not time out. If the command
172+
# times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
173+
#
174+
# If the command does not respond to SIGKILL, it will hang this method.
175+
#
169176
# @return [Git::CommandLineResult] the output of the command
170177
#
171178
# This result of running the command.
172179
#
173180
# @raise [ArgumentError] if `args` is not an array of strings
174181
# @raise [Git::SignaledError] if the command was terminated because of an uncaught signal
175182
# @raise [Git::FailedError] if the command returned a non-zero exitstatus
183+
# @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
184+
# @raise [Git::TimeoutError] if the command times out
176185
#
177-
def run(*args, out:, err:, normalize:, chomp:, merge:, chdir: nil)
186+
def run(*args, out:, err:, normalize:, chomp:, merge:, chdir: nil, timeout: nil)
178187
git_cmd = build_git_cmd(args)
179188
out ||= StringIO.new
180189
err ||= (merge ? out : StringIO.new)
181-
status = execute(git_cmd, out, err, chdir: (chdir || :not_set))
190+
status = execute(git_cmd, out, err, chdir: (chdir || :not_set), timeout: timeout)
182191

183-
process_result(git_cmd, status, out, err, normalize, chomp)
192+
process_result(git_cmd, status, out, err, normalize, chomp, timeout)
184193
end
185194

186195
private
@@ -258,17 +267,24 @@ def raise_pipe_error(git_cmd, pipe_name, pipe)
258267
#
259268
# @param cmd [Array<String>] the git command to execute
260269
# @param chdir [String] the directory to run the command in
270+
# @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete
271+
#
272+
# If timeout is zero of nil, the command will not time out. If the command
273+
# times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
274+
#
275+
# If the command does not respond to SIGKILL, it will hang this method.
261276
#
262277
# @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
278+
# @raise [Git::TimeoutError] if the command times out
263279
#
264-
# @return [Process::Status] the status of the completed subprocess
280+
# @return [ProcessExecuter::Status] the status of the completed subprocess
265281
#
266282
# @api private
267283
#
268-
def spawn(cmd, out_writers, err_writers, chdir:)
284+
def spawn(cmd, out_writers, err_writers, chdir:, timeout:)
269285
out_pipe = ProcessExecuter::MonitoredPipe.new(*out_writers, chunk_size: 10_000)
270286
err_pipe = ProcessExecuter::MonitoredPipe.new(*err_writers, chunk_size: 10_000)
271-
ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe, chdir: chdir)
287+
ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe, chdir: chdir, timeout: timeout)
272288
ensure
273289
out_pipe.close
274290
err_pipe.close
@@ -313,11 +329,12 @@ def writers(out, err)
313329
#
314330
# @api private
315331
#
316-
def process_result(git_cmd, status, out, err, normalize, chomp)
332+
def process_result(git_cmd, status, out, err, normalize, chomp, timeout)
317333
out_str, err_str = post_process_all([out, err], normalize, chomp)
318334
logger.info { "#{git_cmd} exited with status #{status}" }
319335
logger.debug { "stdout:\n#{out_str.inspect}\nstderr:\n#{err_str.inspect}" }
320336
Git::CommandLineResult.new(git_cmd, status, out_str, err_str).tap do |result|
337+
raise Git::TimeoutError.new(result, timeout) if status.timeout?
321338
raise Git::SignaledError.new(result) if status.signaled?
322339
raise Git::FailedError.new(result) unless status.success?
323340
end
@@ -329,14 +346,23 @@ def process_result(git_cmd, status, out, err, normalize, chomp)
329346
# @param out [#write] the object to write stdout to
330347
# @param err [#write] the object to write stderr to
331348
# @param chdir [String] the directory to run the command in
349+
# @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete
350+
#
351+
# If timeout is zero of nil, the command will not time out. If the command
352+
# times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
353+
#
354+
# If the command does not respond to SIGKILL, it will hang this method.
355+
#
356+
# @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
357+
# @raise [Git::TimeoutError] if the command times out
332358
#
333359
# @return [Git::CommandLineResult] the result of the command to return to the caller
334360
#
335361
# @api private
336362
#
337-
def execute(git_cmd, out, err, chdir:)
363+
def execute(git_cmd, out, err, chdir:, timeout:)
338364
out_writers, err_writers = writers(out, err)
339-
spawn(git_cmd, out_writers, err_writers, chdir: chdir)
365+
spawn(git_cmd, out_writers, err_writers, chdir: chdir, timeout: timeout)
340366
end
341367
end
342368
end

lib/git/config.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ module Git
22

33
class Config
44

5-
attr_writer :binary_path, :git_ssh
5+
attr_writer :binary_path, :git_ssh, :timeout
66

77
def initialize
88
@binary_path = nil
99
@git_ssh = nil
10+
@timeout = nil
1011
end
1112

1213
def binary_path
@@ -17,6 +18,9 @@ def git_ssh
1718
@git_ssh || ENV['GIT_SSH']
1819
end
1920

21+
def timeout
22+
@timeout || (ENV['GIT_TIMEOUT'] && ENV['GIT_TIMEOUT'].to_i)
23+
end
2024
end
2125

2226
end

lib/git/lib.rb

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def clone(repository_url, directory, opts = {})
115115
arr_opts << repository_url
116116
arr_opts << clone_dir
117117

118-
command('clone', *arr_opts)
118+
command('clone', *arr_opts, timeout: opts[:timeout])
119119

120120
return_base_opts_from_clone(clone_dir, opts)
121121
end
@@ -1191,8 +1191,48 @@ def command_line
11911191
Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
11921192
end
11931193

1194-
def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil)
1195-
result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir)
1194+
# Runs a git command and returns the output
1195+
#
1196+
# @param args [Array] the git command to run and its arguments
1197+
#
1198+
# This should exclude the 'git' command itself and global options.
1199+
#
1200+
# For example, to run `git log --pretty=oneline`, you would pass `['log',
1201+
# '--pretty=oneline']`
1202+
#
1203+
# @param out [String, nil] the path to a file or an IO to write the command's
1204+
# stdout to
1205+
#
1206+
# @param err [String, nil] the path to a file or an IO to write the command's
1207+
# stdout to
1208+
#
1209+
# @param normalize [Boolean] true to normalize the output encoding
1210+
#
1211+
# @param chomp [Boolean] true to remove trailing newlines from the output
1212+
#
1213+
# @param merge [Boolean] true to merge stdout and stderr
1214+
#
1215+
# @param chdir [String, nil] the directory to run the command in
1216+
#
1217+
# @param timeout [Numeric, nil] the maximum time to wait for the command to
1218+
# complete
1219+
#
1220+
# @see Git::CommandLine#run
1221+
#
1222+
# @return [String] the command's stdout (or merged stdout and stderr if `merge`
1223+
# is true)
1224+
#
1225+
# @raise [Git::GitExecuteError] if the command fails
1226+
#
1227+
# The exception's `result` attribute is a {Git::CommandLineResult} which will
1228+
# contain the result of the command including the exit status, stdout, and
1229+
# stderr.
1230+
#
1231+
# @api private
1232+
#
1233+
def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil, timeout: nil)
1234+
timeout = timeout || Git.config.timeout
1235+
result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir, timeout: timeout)
11961236
result.stdout
11971237
end
11981238

0 commit comments

Comments
 (0)