Class: Shipit::Command

Inherits:
Object
  • Object
show all
Defined in:
lib/shipit/command.rb

Defined Under Namespace

Classes: Failed

Constant Summary collapse

MAX_READ =
64.kilobytes
Error =
Class.new(StandardError)
NotFound =
Class.new(Error)
Denied =
Class.new(Error)
TimedOut =
Class.new(Error)
BASE_ENV =
unbundled_env.merge((ENV.keys - unbundled_env.keys).map { |k| [k, nil] }.to_h)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(*args, chdir:, default_timeout: Shipit.default_inactivity_timeout, env: {}) ⇒ Command

Returns a new instance of Command.



31
32
33
34
35
36
37
# File 'lib/shipit/command.rb', line 31

def initialize(*args, chdir:, default_timeout: Shipit.default_inactivity_timeout, env: {})
  @args, options = parse_arguments(args)
  @timeout = parse_timeout(options['timeout'] || options[:timeout]) || default_timeout
  @env = env.transform_values { |v| v&.to_s }
  @chdir = chdir.to_s
  @timed_out = false
end

Instance Attribute Details

#argsObject (readonly)

Returns the value of attribute args.



29
30
31
# File 'lib/shipit/command.rb', line 29

def args
  @args
end

#chdirObject (readonly)

Returns the value of attribute chdir.



29
30
31
# File 'lib/shipit/command.rb', line 29

def chdir
  @chdir
end

#envObject (readonly)

Returns the value of attribute env.



29
30
31
# File 'lib/shipit/command.rb', line 29

def env
  @env
end

#outObject (readonly)

Returns the value of attribute out.



29
30
31
# File 'lib/shipit/command.rb', line 29

def out
  @out
end

#pidObject (readonly)

Returns the value of attribute pid.



29
30
31
# File 'lib/shipit/command.rb', line 29

def pid
  @pid
end

#timeoutObject (readonly)

Returns the value of attribute timeout.



29
30
31
# File 'lib/shipit/command.rb', line 29

def timeout
  @timeout
end

Instance Method Details

#codeObject



246
247
248
# File 'lib/shipit/command.rb', line 246

def code
  @status&.exitstatus
end

#exit_messageObject



61
62
63
# File 'lib/shipit/command.rb', line 61

def exit_message
  "#{self} #{termination_status}"
end

#interpolate_environment_variables(argument) ⇒ Object



51
52
53
54
55
# File 'lib/shipit/command.rb', line 51

def interpolate_environment_variables(argument)
  return argument.map { |a| interpolate_environment_variables(a) } if argument.is_a?(Array)

  EnvironmentVariables.with(env).interpolate(argument)
end

#interpolated_argumentsObject



81
82
83
# File 'lib/shipit/command.rb', line 81

def interpolated_arguments
  interpolate_environment_variables(@args)
end

#kill(sig) {|red("Sending SIG#{sig} to PID #{@pid}\n")| ... } ⇒ Object

Yields:

  • (red("Sending SIG#{sig} to PID #{@pid}\n"))


206
207
208
209
# File 'lib/shipit/command.rb', line 206

def kill(sig)
  yield red("Sending SIG#{sig} to PID #{@pid}\n")
  Process.kill(sig, @pid)
end

#kill_and_wait(sig, wait, &block) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/shipit/command.rb', line 183

def kill_and_wait(sig, wait, &block)
  retry_count = 5
  kill(sig, &block)
  begin
    with_timeout(wait) do
      read_stream(@out, &block)
    end
  rescue TimedOut
  rescue Errno::EIO # EIO is somewhat expected on Linux: http://stackoverflow.com/a/10306782
    # If we try to read the stream right after sending a signal, we often get an Errno::EIO.
    return true if reap_child!(block: false)

    # If we let the child a little bit of time, it solves it.
    retry_count -= 1
    if retry_count > 0
      sleep(0.05)
      retry
    end
  end
  reap_child!(block: false)
  true
end

#output_timed_out?Boolean

Returns:

  • (Boolean)


138
139
140
141
# File 'lib/shipit/command.rb', line 138

def output_timed_out?
  @last_output_at ||= Time.now.to_i
  (@last_output_at + timeout) < Time.now.to_i
end

#parse_arguments(arguments) ⇒ Object



227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/shipit/command.rb', line 227

def parse_arguments(arguments)
  options = {}
  args = arguments.flatten.map do |argument|
    case argument
    when Hash
      options.merge!(argument.values.first)
      argument.keys.first
    else
      argument
    end
  end

  [args.map(&:to_s), options]
end

#parse_timeout(timeout) ⇒ Object



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/shipit/command.rb', line 211

def parse_timeout(timeout)
  case timeout
  when String
    begin
      Duration.parse(timeout).to_i
    rescue Duration::ParseError
      # If given garbage we fallback to the default.
      # It's not ideal but we don't have a good way to notify about
      # syntax errors.
      nil
    end
  else
    timeout
  end
end

#read_stream(io) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/shipit/command.rb', line 151

def read_stream(io)
  touch_last_output_at
  loop do
    yield_control
    yield io.read_nonblock(MAX_READ)
    touch_last_output_at
  rescue IO::WaitReadable
    if output_timed_out?
      @timed_out = true
      raise TimedOut
    end
    IO.select([io], nil, nil, 1)
    retry
  end
rescue EOFError
end

#reap_child!(block: true) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
# File 'lib/shipit/command.rb', line 254

def reap_child!(block: true)
  return @status if @status
  return unless running? # Command was never started e.g. permission denied, not found etc

  if block
    _, @status = Process.waitpid2(@pid)
  elsif res = Process.waitpid2(@pid, Process::WNOHANG)
    @status = res[1]
  end
  @status
end

#red(text) ⇒ Object



123
124
125
# File 'lib/shipit/command.rb', line 123

def red(text)
  "\033[1;31m#{text}\033[0m"
end

#runObject



65
66
67
68
69
70
71
# File 'lib/shipit/command.rb', line 65

def run
  output = []
  stream do |out|
    output << out
  end
  output.join
end

#run!Object



73
74
75
76
77
78
79
# File 'lib/shipit/command.rb', line 73

def run!
  output = []
  stream! do |out|
    output << out
  end
  output.join
end

#running?Boolean

Returns:

  • (Boolean)


242
243
244
# File 'lib/shipit/command.rb', line 242

def running?
  !!pid && !@status
end

#signaled?Boolean

Returns:

  • (Boolean)


250
251
252
# File 'lib/shipit/command.rb', line 250

def signaled?
  @status.signaled?
end

#start(&block) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/shipit/command.rb', line 85

def start(&block)
  return if @started

  @control_block = block
  @out = @pid = nil
  FileUtils.mkdir_p(@chdir)
  begin
    @out, child_in, @pid = PTY.spawn(unbundled_env, *interpolated_arguments, chdir: @chdir)
    child_in.close
  rescue Errno::ENOENT
    raise NotFound, "#{Shellwords.split(interpolated_arguments.first).first}: command not found"
  rescue Errno::EACCES
    raise Denied, "#{Shellwords.split(interpolated_arguments.first).first}: Permission denied"
  end
  @started = true
  self
end

#stream(&block) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/shipit/command.rb', line 107

def stream(&block)
  start
  begin
    read_stream(@out, &block)
  rescue TimedOut => e
    yield "#{red("No output received in the last #{timeout} seconds.")}\n"
    terminate!(&block)
    raise e
  rescue Errno::EIO # Somewhat expected on Linux: http://stackoverflow.com/a/10306782
  end

  self
ensure
  reap_child!
end

#stream!(&block) ⇒ Object

Raises:



127
128
129
130
131
132
# File 'lib/shipit/command.rb', line 127

def stream!(&block)
  stream(&block)
  raise Failed.new(exit_message, code) unless success?

  self
end

#success?Boolean

Returns:

  • (Boolean)


57
58
59
# File 'lib/shipit/command.rb', line 57

def success?
  !code.nil? && code.zero?
end

#terminate!(&block) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/shipit/command.rb', line 168

def terminate!(&block)
  kill_and_wait('INT', 5, &block) ||
    kill_and_wait('INT', 2, &block) ||
    kill_and_wait('TERM', 5, &block) ||
    kill_and_wait('TERM', 2, &block) ||
    kill('KILL', &block)
rescue Errno::ECHILD, Errno::ESRCH
  true # much success
ensure
  begin
    read_stream(@out, &block)
  rescue StandardError
  end
end

#termination_statusObject



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/shipit/command.rb', line 266

def termination_status
  if running?
    "is running"
  elsif success?
    "terminated successfully"
  elsif timed_out? && signaled?
    "timed out and terminated with #{Signal.signame(@status.termsig)} signal"
  elsif timed_out?
    "timed out and terminated with exit status #{exitstatus}"
  elsif signaled?
    "terminated with #{Signal.signame(@status.termsig)} signal"
  else
    "terminated with exit status #{code}"
  end
end

#timed_out?Boolean

Returns:

  • (Boolean)


134
135
136
# File 'lib/shipit/command.rb', line 134

def timed_out?
  @timed_out
end

#to_sObject



47
48
49
# File 'lib/shipit/command.rb', line 47

def to_s
  @args.join(' ')
end

#touch_last_output_atObject



143
144
145
# File 'lib/shipit/command.rb', line 143

def touch_last_output_at
  @last_output_at = Time.now.to_i
end

#unbundled_envObject



103
104
105
# File 'lib/shipit/command.rb', line 103

def unbundled_env
  BASE_ENV.merge('PATH' => "#{Shipit.shell_paths.join(':')}:#{ENV['PATH']}").merge(@env.stringify_keys)
end

#with_timeout(new_timeout) ⇒ Object



39
40
41
42
43
44
45
# File 'lib/shipit/command.rb', line 39

def with_timeout(new_timeout)
  old_timeout = timeout
  @timeout = new_timeout
  yield
ensure
  @timeout = old_timeout
end

#yield_controlObject



147
148
149
# File 'lib/shipit/command.rb', line 147

def yield_control
  @control_block&.call
end