right_popen 1.0.21 → 1.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,5 @@
1
1
  #-- -*- mode: ruby; encoding: utf-8 -*-
2
- # Copyright: Copyright (c) 2011 RightScale, Inc.
2
+ # Copyright: Copyright (c) 2011-2013 RightScale, Inc.
3
3
  #
4
4
  # Permission is hereby granted, free of charge, to any person obtaining
5
5
  # a copy of this software and associated documentation files (the
@@ -23,20 +23,87 @@
23
23
 
24
24
  require 'etc'
25
25
 
26
+ require ::File.expand_path(::File.join(::File.dirname(__FILE__), '..', 'process_base'))
27
+ require ::File.expand_path(::File.join(::File.dirname(__FILE__), '..', 'process_status'))
28
+
26
29
  module RightScale
27
30
  module RightPopen
28
- class Process
29
- attr_reader :pid, :stdin, :stdout, :stderr, :status_fd
30
- attr_accessor :status
31
-
32
- def initialize(parameters={})
33
- parameters[:locale] = true unless parameters.has_key?(:locale)
34
- @parameters = parameters
35
- @status_fd = nil
31
+ class Process < ProcessBase
32
+
33
+ def initialize(options={})
34
+ super(options)
35
+ end
36
+
37
+ # Determines if the process is still running.
38
+ #
39
+ # === Return
40
+ # @return [TrueClass|FalseClass] true if running
41
+ def alive?
42
+ raise ProcessError.new('Process not started') unless @pid
43
+ unless @status
44
+ begin
45
+ ignored, status = ::Process.waitpid2(@pid, ::Process::WNOHANG)
46
+ @status = status
47
+ rescue
48
+ wait_for_exit_status
49
+ end
50
+ end
51
+ @status.nil?
52
+ end
53
+
54
+ # Linux must only read streams that are selected for read, even on child
55
+ # death. the issue is that a child process can (inexplicably) close one of
56
+ # the streams but continue writing to the other and this will cause the
57
+ # parent to hang reading the stream until the child goes away.
58
+ #
59
+ # === Return
60
+ # @return [TrueClass|FalseClass] true if draining all
61
+ def drain_all_upon_death?
62
+ false
63
+ end
64
+
65
+ # @return [Array] escalating termination signals for this platform
66
+ def signals_for_interrupt
67
+ ['INT', 'TERM', 'KILL']
36
68
  end
37
69
 
38
- def fork(cmd)
39
- @cmd = cmd
70
+ # blocks waiting for process exit status.
71
+ #
72
+ # === Return
73
+ # @return [ProcessStatus] exit status
74
+ def wait_for_exit_status
75
+ raise ProcessError.new('Process not started') unless @pid
76
+ unless @status
77
+ begin
78
+ ignored, status = ::Process.waitpid2(@pid)
79
+ @status = status
80
+ rescue
81
+ # ignored
82
+ end
83
+ end
84
+ @status
85
+ end
86
+
87
+ # spawns (forks) a child process using given command and handler target in
88
+ # linux-specific manner.
89
+ #
90
+ # must be overridden and override must call super.
91
+ #
92
+ # === Parameters
93
+ # @param [String|Array] cmd as shell command or binary to execute
94
+ # @param [Object] target that implements all handlers (see TargetProxy)
95
+ #
96
+ # === Return
97
+ # @return [TrueClass] always true
98
+ def spawn(cmd, target)
99
+ super(cmd, target)
100
+
101
+ # garbage collect any open file descriptors from past executions before
102
+ # forking to prevent them being inherited. also reduces memory footprint
103
+ # since forking will duplicate everything in memory for child process.
104
+ ::GC.start
105
+
106
+ # create pipes.
40
107
  stdin_r, stdin_w = IO.pipe
41
108
  stdout_r, stdout_w = IO.pipe
42
109
  stderr_r, stderr_w = IO.pipe
@@ -45,24 +112,28 @@ module RightScale
45
112
  [stdin_r, stdin_w, stdout_r, stdout_w,
46
113
  stderr_r, stderr_w, status_r, status_w].each {|fdes| fdes.sync = true}
47
114
 
48
- @pid = Kernel::fork do
115
+ @pid = ::Kernel::fork do
49
116
  begin
50
117
  stdin_w.close
51
- STDIN.reopen stdin_r
118
+ ::STDIN.reopen stdin_r
52
119
 
53
120
  stdout_r.close
54
- STDOUT.reopen stdout_w
121
+ ::STDOUT.reopen stdout_w
55
122
 
56
123
  stderr_r.close
57
- STDERR.reopen stderr_w
124
+ ::STDERR.reopen stderr_w
58
125
 
59
126
  status_r.close
60
- status_w.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
61
-
62
- ObjectSpace.each_object(IO) do |io|
63
- if ![STDIN, STDOUT, STDERR, status_w].include?(io)
64
- io.close unless io.closed?
65
- end
127
+ status_w.fcntl(::Fcntl::F_SETFD, ::Fcntl::FD_CLOEXEC)
128
+
129
+ unless @options[:inherit_io]
130
+ ::ObjectSpace.each_object(IO) do |io|
131
+ if ![::STDIN, ::STDOUT, ::STDERR, status_w].include?(io)
132
+ # be careful to not allow streams in a bad state from the
133
+ # parent process to prevent child process running.
134
+ (io.close rescue nil) unless (io.closed? rescue true)
135
+ end
136
+ end
66
137
  end
67
138
 
68
139
  if group = get_group
@@ -75,24 +146,34 @@ module RightScale
75
146
  ::Process.uid = user
76
147
  end
77
148
 
78
- Dir.chdir(@parameters[:directory]) if @parameters[:directory]
79
-
80
- ENV["LC_ALL"] = "C" if @parameters[:locale]
149
+ if umask = get_umask
150
+ ::File.umask(umask)
151
+ end
81
152
 
82
- @parameters[:environment].each do |key,value|
83
- ENV[key.to_s] = value.to_s
84
- end if @parameters[:environment]
153
+ # avoid chdir when pwd is already correct due to asinine printed
154
+ # warning from chdir block for what is basically a no-op.
155
+ working_directory = @options[:directory]
156
+ if working_directory &&
157
+ ::File.expand_path(working_directory) != ::File.expand_path(::Dir.pwd)
158
+ ::Dir.chdir(working_directory)
159
+ end
85
160
 
86
- File.umask(get_umask) if @parameters[:umask]
161
+ environment_hash = {}
162
+ environment_hash['LC_ALL'] = 'C' if @options[:locale]
163
+ environment_hash.merge!(@options[:environment]) if @options[:environment]
164
+ environment_hash.each do |key, value|
165
+ ::ENV[key.to_s] = value.to_s if value
166
+ end
87
167
 
88
168
  if cmd.kind_of?(Array)
169
+ cmd = cmd.map { |c| c.to_s } #exec only likes string arguments
89
170
  exec(*cmd)
90
171
  else
91
- exec("sh", "-c", cmd)
172
+ exec('sh', '-c', cmd.to_s) # allows shell commands for cmd string
92
173
  end
93
- raise 'forty-two'
94
- rescue Exception => e
95
- Marshal.dump(e, status_w)
174
+ raise 'Unreachable code'
175
+ rescue ::Exception => e
176
+ ::Marshal.dump(e, status_w)
96
177
  end
97
178
  status_w.close
98
179
  exit!
@@ -106,11 +187,21 @@ module RightScale
106
187
  @stdout = stdout_r
107
188
  @stderr = stderr_r
108
189
  @status_fd = status_r
190
+ start_timer
191
+ true
192
+ rescue
193
+ # catch-all for failure to spawn process ensuring a non-nil status. the
194
+ # PID most likely is nil but the exit handler can be invoked for async.
195
+ safe_close_io
196
+ @status = ::RightScale::RightPopen::ProcessStatus.new(@pid, 1)
197
+ raise
109
198
  end
110
199
 
200
+ # @deprecated this seems like test harness code smell, not production code.
111
201
  def wait_for_exec
202
+ warn 'WARNING: RightScale::RightPopen::Process#wait_for_exec is deprecated in lib and will be moved to spec'
112
203
  begin
113
- e = Marshal.load @status_fd
204
+ e = ::Marshal.load(@status_fd)
114
205
  # thus meaning that the process failed to exec...
115
206
  @stdin.close
116
207
  @stdout.close
@@ -126,28 +217,29 @@ module RightScale
126
217
  private
127
218
 
128
219
  def get_user
129
- user = @parameters[:user] || nil
130
- unless user.kind_of?(Integer)
131
- user = Etc.getpwnam(user).uid if user
220
+ if user = @options[:user]
221
+ user = Etc.getpwnam(user).uid unless user.kind_of?(Integer)
132
222
  end
133
223
  user
134
224
  end
135
225
 
136
226
  def get_group
137
- group = @parameters[:group] || nil
138
- unless group.kind_of?(Integer)
139
- group = Etc.getgrnam(group).gid if group
227
+ if group = @options[:group]
228
+ group = Etc.getgrnam(group).gid unless group.kind_of?(Integer)
140
229
  end
141
230
  group
142
231
  end
143
232
 
144
233
  def get_umask
145
- if @parameters[:umask].respond_to?(:oct)
146
- value = @parameters[:umask].oct
147
- else
148
- value = @parameters[:umask].to_i
234
+ if umask = @options[:umask]
235
+ if umask.respond_to?(:oct)
236
+ umask = umask.oct
237
+ else
238
+ umask = umask.to_i
239
+ end
240
+ umask = umask & 007777
149
241
  end
150
- value & 007777
242
+ umask
151
243
  end
152
244
  end
153
245
  end
@@ -26,6 +26,8 @@ require File.expand_path(File.join(File.dirname(__FILE__), "accumulator"))
26
26
 
27
27
  module RightScale
28
28
  module RightPopen
29
+
30
+ # @deprecated this seems like test harness code smell, not production code
29
31
  module Utilities
30
32
  module_function
31
33
 
@@ -77,14 +79,15 @@ module RightScale
77
79
  alias_method :spawn, :run_collecting_output
78
80
 
79
81
  def run_with_blocks(cmd, stdin_block, stdout_block, stderr_block, parameters={})
82
+ warn 'WARNING: RightScale::RightPopen::Utilities are deprecated and will be removed.'
80
83
  process = Process.new(parameters)
81
- process.fork(cmd)
84
+ process.spawn(cmd, ::RightScale::RightPopen::TargetProxy.new(parameters))
82
85
  process.wait_for_exec
83
86
  a = Accumulator.new(process,
84
87
  [process.stdout, process.stderr], [stdout_block, stderr_block],
85
88
  [process.stdin], [stdin_block])
86
89
  a.run_to_completion
87
- process.status[1]
90
+ a.status[1]
88
91
  end
89
92
  end
90
93
  end
@@ -0,0 +1,339 @@
1
+ #-- -*- mode: ruby; encoding: utf-8 -*-
2
+ # Copyright: Copyright (c) 2013 RightScale, Inc.
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining
5
+ # a copy of this software and associated documentation files (the
6
+ # 'Software'), to deal in the Software without restriction, including
7
+ # without limitation the rights to use, copy, modify, merge, publish,
8
+ # distribute, sublicense, and/or sell copies of the Software, and to
9
+ # permit persons to whom the Software is furnished to do so, subject to
10
+ # the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
19
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
20
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ #++
23
+
24
+ require 'thread'
25
+
26
+ module RightScale
27
+ module RightPopen
28
+ class ProcessBase
29
+
30
+ class ProcessError < Exception; end
31
+
32
+ attr_reader :pid, :stdin, :stdout, :stderr, :status_fd, :status
33
+ attr_reader :start_time, :stop_time, :channels_to_finish
34
+
35
+ # === Parameters
36
+ # @param [Hash] options see RightScale.popen3_async for details
37
+ def initialize(options={})
38
+ @options = options
39
+ @stdin = nil
40
+ @stdout = nil
41
+ @stderr = nil
42
+ @status_fd = nil
43
+ @last_interrupt = nil
44
+ @pid = nil
45
+ @start_time = nil
46
+ @stop_time = nil
47
+ @watch_directory = nil
48
+ @size_limit_bytes = nil
49
+ @cmd = nil
50
+ @target = nil
51
+ @status = nil
52
+ @channels_to_finish = nil
53
+ @needs_watching = !!(
54
+ @options[:timeout_seconds] ||
55
+ @options[:size_limit_bytes] ||
56
+ @options[:watch_handler])
57
+ end
58
+
59
+ # Determines if the process is still running.
60
+ #
61
+ # === Return
62
+ # @return [TrueClass|FalseClass] true if running
63
+ def alive?
64
+ raise NotImplementedError, 'Must be overridden'
65
+ end
66
+
67
+ # Determines whether or not to drain all open streams upon death of child
68
+ # or else only those where IO.select indicates data available. This
69
+ # decision is platform-specific.
70
+ #
71
+ # === Return
72
+ # @return [TrueClass|FalseClass] true if draining all
73
+ def drain_all_upon_death?
74
+ raise NotImplementedError, 'Must be overridden'
75
+ end
76
+
77
+ # Determines if this process needs to be watched (beyond waiting for the
78
+ # process to exit).
79
+ #
80
+ # === Return
81
+ # @return [TrueClass|FalseClass] true if needs watching
82
+ def needs_watching?; @needs_watching; end
83
+
84
+ # Determines if timeout on child process has expired, if any.
85
+ #
86
+ # === Return
87
+ # @return [TrueClass|FalseClass] true if timer expired
88
+ def timer_expired?
89
+ !!(@stop_time && Time.now >= @stop_time)
90
+ end
91
+
92
+ # Determines if total size of files created by child process has exceeded
93
+ # the limit specified, if any.
94
+ #
95
+ # === Return
96
+ # @return [TrueClass|FalseClass] true if size limit exceeded
97
+ def size_limit_exceeded?
98
+ if @watch_directory
99
+ globbie = ::File.join(@watch_directory, '**/*')
100
+ size = 0
101
+ ::Dir.glob(globbie) do |f|
102
+ size += ::File.stat(f).size rescue 0 if ::File.file?(f)
103
+ break if size > @size_limit_bytes
104
+ end
105
+ size > @size_limit_bytes
106
+ else
107
+ false
108
+ end
109
+ end
110
+
111
+ # @return [TrueClass|FalseClass] interrupted as true if child process was interrupted by watcher
112
+ def interrupted?; !!@last_interrupt; end
113
+
114
+ # Performs all process operations in synchronous fashion. It is possible
115
+ # for errors or callback behavior to conditionally short-circuit the
116
+ # synchronous operations.
117
+ #
118
+ # === Parameters
119
+ # @param [String|Array] cmd as shell command or binary to execute
120
+ # @param [Object] target that implements all handlers (see TargetProxy)
121
+ #
122
+ # === Return
123
+ # @return [TrueClass] always true
124
+ def sync_all(cmd, target)
125
+ spawn(cmd, target)
126
+ sync_exit_with_target if sync_pid_with_target
127
+ true
128
+ end
129
+
130
+ # Spawns a child process using given command and handler target in a
131
+ # platform-independant manner.
132
+ #
133
+ # must be overridden and override must call super.
134
+ #
135
+ # === Parameters
136
+ # @param [String|Array] cmd as shell command or binary to execute
137
+ # @param [Object] target that implements all handlers (see TargetProxy)
138
+ #
139
+ # === Return
140
+ # @return [TrueClass] always true
141
+ def spawn(cmd, target)
142
+ @cmd = cmd
143
+ @target = target
144
+ @kill_time = nil
145
+ @pid = nil
146
+ @status = nil
147
+ @last_interrupt = nil
148
+ @channels_to_finish = nil
149
+
150
+ if @size_limit_bytes = @options[:size_limit_bytes]
151
+ @watch_directory = @options[:watch_directory] || @options[:directory] || ::Dir.pwd
152
+ end
153
+ end
154
+
155
+ # Performs initial handler callbacks before consuming I/O. Represents any
156
+ # code that must not be invoked twice (unlike sync_exit_with_target).
157
+ #
158
+ # === Return
159
+ # @return [TrueClass|FalseClass] true to begin watch, false to abandon
160
+ def sync_pid_with_target
161
+ # early handling in case caller wants to stream to/from the pipes
162
+ # directly (as in a classic popen3/4 scenario).
163
+ @target.pid_handler(@pid)
164
+ if input_text = @options[:input]
165
+ @stdin.write(input_text)
166
+ end
167
+
168
+ # one-time initialization of the stateful channels_to_finish hash to
169
+ # allow for multiple invocations of the sync_exit_with_target with a
170
+ # possible abandon in between.
171
+ #
172
+ # note that calling IO.select on pipes which have already had all
173
+ # of their output consumed can cause segfault (in Ubuntu?) so it is
174
+ # important to keep track of when all I/O has been consumed.
175
+ @channels_to_finish = [
176
+ [:stdout_handler, @stdout],
177
+ [:stderr_handler, @stderr],
178
+ ]
179
+ @channels_to_finish << [:status_fd, @status_fd] if @status_fd
180
+
181
+ # sync watch_handler has the option to abandon watch as soon as child
182
+ # process comes alive and before streaming any output.
183
+ if @target.watch_handler(self)
184
+ # can close stdin if not returning control to caller.
185
+ @stdin.close rescue nil
186
+ return true
187
+ else
188
+ # caller is reponsible for draining and/or closing all pipes. this can
189
+ # be accomplished by explicity calling either sync_exit_with_target or
190
+ # safe_close_io plus wait_for_exit_status (if data has been read from
191
+ # the I/O streams). it is unsafe to read some data and then call
192
+ # sync_exit_with_target because IO.select may segfault if all of the
193
+ # data in a stream has been already consumed.
194
+ return false
195
+ end
196
+ rescue
197
+ safe_close_io
198
+ raise
199
+ end
200
+
201
+ # Monitors I/O from child process and directly notifies target of any
202
+ # events. Blocks until child exits.
203
+ #
204
+ # === Return
205
+ # @return [TrueClass] always true
206
+ def sync_exit_with_target
207
+ abandon = false
208
+ last_exception = nil
209
+ begin
210
+ while true
211
+ channels_to_watch = @channels_to_finish.map { |ctf| ctf.last }
212
+ ready = ::IO.select(channels_to_watch, nil, nil, 0.1) rescue nil
213
+ dead = !alive?
214
+ channels_to_read = ready && ready.first
215
+ if dead && drain_all_upon_death?
216
+ # finish reading all dead channels.
217
+ channels_to_read = @channels_to_finish.map { |ctf| ctf.last }
218
+ end
219
+ if channels_to_read
220
+ channels_to_read.each do |channel|
221
+ index = @channels_to_finish.index { |ctf| ctf.last == channel }
222
+ key = @channels_to_finish[index].first
223
+ data = dead ? channel.gets(nil) : channel.gets
224
+ if data
225
+ if key == :status_fd
226
+ last_exception = ::Marshal.load(data)
227
+ else
228
+ @target.method(key).call(data)
229
+ end
230
+ else
231
+ # nothing on channel indicates EOF
232
+ @channels_to_finish.delete_at(index)
233
+ end
234
+ end
235
+ end
236
+ if dead
237
+ break
238
+ elsif (interrupted? || timer_expired? || size_limit_exceeded?)
239
+ interrupt
240
+ elsif abandon = !@target.watch_handler(self)
241
+ return true # bypass any remaining callbacks
242
+ end
243
+ end
244
+ wait_for_exit_status
245
+ @target.timeout_handler if timer_expired?
246
+ @target.size_limit_handler if size_limit_exceeded?
247
+ @target.exit_handler(@status)
248
+
249
+ # re-raise exception from fork, if any.
250
+ case last_exception
251
+ when nil
252
+ # all good
253
+ when ::Exception
254
+ raise last_exception
255
+ else
256
+ raise "Unknown failure: saw #{last_exception.inspect} on status channel."
257
+ end
258
+ ensure
259
+ # abandon will not close I/O objects; caller takes responsibility via
260
+ # process object passed to watch_handler. if anyone calls interrupt
261
+ # then close I/O regardless of abandon to try to force child to die.
262
+ safe_close_io if !abandon || interrupted?
263
+ end
264
+ true
265
+ end
266
+
267
+ # blocks waiting for process exit status.
268
+ #
269
+ # === Return
270
+ # @return [ProcessStatus] exit status
271
+ def wait_for_exit_status
272
+ raise NotImplementedError, 'Must be overridden'
273
+ end
274
+
275
+ # @return [Array] escalating termination signals for this platform
276
+ def signals_for_interrupt
277
+ raise NotImplementedError, 'Must be overridden'
278
+ end
279
+
280
+ # Interrupts the running process (without abandoning watch) in increasing
281
+ # degrees of signalled severity.
282
+ #
283
+ # === Return
284
+ # @return [TrueClass|FalseClass] true if process was alive and interrupted, false if dead before (first) interrupt
285
+ def interrupt
286
+ while alive?
287
+ if !@kill_time || Time.now >= @kill_time
288
+ # soft then hard interrupt (assumed to be called periodically until
289
+ # process is gone).
290
+ sigs = signals_for_interrupt
291
+ if @last_interrupt
292
+ last_index = sigs.index(@last_interrupt)
293
+ next_interrupt = sigs[last_index + 1]
294
+ else
295
+ next_interrupt = sigs.first
296
+ end
297
+ unless next_interrupt
298
+ raise ::RightScale::RightPopen::ProcessBase::ProcessError
299
+ 'Unable to kill child process'
300
+ end
301
+ @last_interrupt = next_interrupt
302
+
303
+ # kill
304
+ result = ::Process.kill(next_interrupt, @pid) rescue nil
305
+ if result
306
+ @kill_time = Time.now + 3 # more seconds until next attempt
307
+ break
308
+ end
309
+ end
310
+ end
311
+ interrupted?
312
+ end
313
+
314
+ # Safely closes any open I/O objects associated with this process.
315
+ #
316
+ # === Return
317
+ # @return [TrueClass] alway true
318
+ def safe_close_io
319
+ @stdin.close rescue nil if @stdin && !@stdin.closed?
320
+ @stdout.close rescue nil if @stdout && !@stdout.closed?
321
+ @stderr.close rescue nil if @stderr && !@stderr.closed?
322
+ @status_fd.close rescue nil if @status_fd && !@status_fd.closed?
323
+ true
324
+ end
325
+
326
+ protected
327
+
328
+ def start_timer
329
+ # start timer when process comes alive (ruby processes are slow to
330
+ # start in Windows, etc.).
331
+ raise ProcessError.new("Process not started") unless @pid
332
+ @start_time = ::Time.now
333
+ @stop_time = @options[:timeout_seconds] ?
334
+ (@start_time + @options[:timeout_seconds]) :
335
+ nil
336
+ end
337
+ end
338
+ end
339
+ end