right_popen 1.0.21 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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