rubysh 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rubysh/error.rb CHANGED
@@ -17,5 +17,6 @@ module Rubysh
17
17
  class AlreadyClosedError < BaseError; end
18
18
  class AlreadyRunError < BaseError; end
19
19
  class BadExitError < BaseError; end
20
+ class ECHILDError < BaseError; end
20
21
  end
21
22
  end
@@ -15,7 +15,7 @@ module Rubysh
15
15
 
16
16
  attr_accessor :source, :direction, :target
17
17
 
18
- def initialize(source, direction, target)
18
+ def initialize(source, direction, target, opts=nil)
19
19
  unless VALID_DIRECTIONS.include?(direction)
20
20
  raise Rubysh::Error::BaseError.new("Direction must be one of #{VALID_DIRECTIONS.join(', ')}, not #{direction.inspect}")
21
21
  end
@@ -31,6 +31,7 @@ module Rubysh
31
31
  @source = source
32
32
  @target = target
33
33
  @direction = direction
34
+ @opts = opts || {}
34
35
  end
35
36
 
36
37
  def printable_source
@@ -124,7 +125,8 @@ module Rubysh
124
125
  :buffer => StringIO.new,
125
126
  :target_name => target_name,
126
127
  :read_pos => 0,
127
- :subprocess_fd_number => Util.to_fileno(source)
128
+ :subprocess_fd_number => Util.to_fileno(source),
129
+ :tee => @opts[:tee]
128
130
  }
129
131
  end
130
132
 
data/lib/rubysh/runner.rb CHANGED
@@ -108,8 +108,8 @@ module Rubysh
108
108
  # API for running/waiting
109
109
  def run_async
110
110
  raise Rubysh::Error::AlreadyRunError.new("You have already run this #{self.class} instance. Cannot run again. You can run its command directly though, which will create a fresh #{self.class} instance.") unless @runner_state == :initialized
111
- prepare_io
112
111
  @command.start_async(self)
112
+ prepare_io
113
113
  @runner_state = :started
114
114
  self
115
115
  end
@@ -218,6 +218,14 @@ module Rubysh
218
218
  end
219
219
  end
220
220
 
221
+ def subprocesses
222
+ # Not everything with state has a subprocess
223
+ @state.map do |object, target|
224
+ next unless object.kind_of?(BaseCommand)
225
+ target.fetch(:subprocess)
226
+ end.compact
227
+ end
228
+
221
229
  def do_wait
222
230
  return unless @runner_state == :parallel_io_ran
223
231
  @command.wait(self)
@@ -239,7 +247,7 @@ module Rubysh
239
247
  # Can't build this in the prepare stage because pipes aren't built
240
248
  # there.
241
249
  def prepare_io
242
- @parallel_io = Subprocess::ParallelIO.new(readers, writers)
250
+ @parallel_io = Subprocess::PidAwareParallelIO.new(readers, writers, subprocesses)
243
251
  @parallel_io.on_read do |target_name, data|
244
252
  state = @targets[target_name]
245
253
  buffer = state[:buffer]
@@ -248,6 +256,9 @@ module Rubysh
248
256
  buffer.close_write
249
257
  else
250
258
  Rubysh.log.debug("Just read #{data.inspect} on #{target_name.inspect}")
259
+ tee = state[:tee]
260
+ tee.write(data) if tee
261
+
251
262
  # Seek to end
252
263
  buffer.pos = buffer.length
253
264
  buffer.write(data)
@@ -1,6 +1,7 @@
1
1
  class Rubysh::Subprocess
2
2
  class ParallelIO
3
3
  module EOF; end
4
+ class NothingAvailable < StandardError; end
4
5
 
5
6
  # readers/writers should be hashes mapping {fd => name}
6
7
  def initialize(readers, writers)
@@ -65,16 +66,25 @@ class Rubysh::Subprocess
65
66
  end
66
67
  end
67
68
 
68
- def run_once
69
+ # This method is a stub so it can be extended in subclasses
70
+ def run_once(timeout=nil)
71
+ run_select_loop(timeout)
72
+ end
73
+
74
+ def run_select_loop(timeout)
69
75
  potential_readers = available_readers
70
76
  potential_writers = available_writers
71
77
 
72
78
  begin
73
- ready_readers, ready_writers, _ = IO.select(potential_readers, potential_writers)
79
+ selected = IO.select(potential_readers, potential_writers, nil, timeout)
74
80
  rescue Errno::EINTR
75
81
  retry
82
+ else
83
+ raise NothingAvailable unless selected
76
84
  end
77
85
 
86
+ ready_readers, ready_writers, _ = selected
87
+
78
88
  ready_readers.each do |reader|
79
89
  read_available(reader)
80
90
  end
@@ -84,6 +94,13 @@ class Rubysh::Subprocess
84
94
  end
85
95
  end
86
96
 
97
+ def consume_all_available
98
+ begin
99
+ loop {run_select_loop(0)}
100
+ rescue NothingAvailable
101
+ end
102
+ end
103
+
87
104
  def read_available(reader)
88
105
  begin
89
106
  data = reader.read_nonblock(4096)
@@ -106,7 +123,7 @@ class Rubysh::Subprocess
106
123
  def issue_reader_callback(reader, data)
107
124
  if @on_read
108
125
  name = reader_name(reader)
109
- @on_read.call(name, data)
126
+ @on_read.call(name, data) if name
110
127
  end
111
128
  end
112
129
 
@@ -168,7 +185,7 @@ class Rubysh::Subprocess
168
185
  def issue_writer_callback(writer, data, remaining)
169
186
  if @on_write
170
187
  name = writer_name(writer)
171
- @on_write.call(name, data, remaining)
188
+ @on_write.call(name, data, remaining) if name
172
189
  end
173
190
  end
174
191
 
@@ -0,0 +1,103 @@
1
+ require 'set'
2
+ require 'thread'
3
+
4
+ class Rubysh::Subprocess
5
+ # We can't actually rely on an EOF once our subprocess has died,
6
+ # since it may have forked and a child inherited the parent's
7
+ # fds. (This happens, for example, when using SSH's ControlPersist.)
8
+ #
9
+ # E.g. try Rubysh.run('ruby', 'bad.rb', Rubysh.>) with:
10
+ # # cat bad.rb
11
+ # fork {sleep 1000}
12
+ class PidAwareParallelIO < ParallelIO
13
+ @pids_mutex = Mutex.new
14
+ @parallel_ios = {}
15
+ @old_sigchld_handler = nil
16
+
17
+ def self.register_parallel_io(parallel_io, breaker_writer)
18
+ @pids_mutex.synchronize do
19
+ register_sigchld_handler if @parallel_ios.length == 0
20
+ @parallel_ios[parallel_io] = breaker_writer
21
+
22
+ # This is needed in case the SIGCHLD is handled before the
23
+ # writer is stored.
24
+ trigger_breaker(breaker_writer)
25
+ end
26
+ end
27
+
28
+ def self.deregister_parallel_io(parallel_io)
29
+ @pids_mutex.synchronize do
30
+ @parallel_ios.delete(parallel_io)
31
+ deregister_sigchld_handler if @parallel_ios.length == 0
32
+ end
33
+ end
34
+
35
+ def self.handle_sigchld
36
+ # It's ok for this operation to race against other
37
+ # threads. Break loop on all currently active selectors. This
38
+ # could in theory cause a thundering herd, but it's probably not
39
+ # worth the work to defend against.
40
+ @parallel_ios.values.each {|writer| trigger_breaker(writer)}
41
+ end
42
+
43
+ def self.trigger_breaker(writer)
44
+ begin
45
+ writer.write_nonblock('a') unless writer.closed?
46
+ rescue Errno::EAGAIN, Errno::EPIPE
47
+ end
48
+ end
49
+
50
+ def self.register_sigchld_handler
51
+ @old_sigchld_handler = Signal.trap('CHLD') {handle_sigchld}
52
+ # MRI returns nil for a DEFAULT handler, but it also treats nil
53
+ # as IGNORE.
54
+ @old_sigchld_handler ||= 'DEFAULT'
55
+ end
56
+
57
+ def self.deregister_sigchld_handler
58
+ Signal.trap('CHLD', @old_sigchld_handler)
59
+ end
60
+
61
+ # readers/writers should be hashes mapping {fd => name}
62
+ def initialize(readers, writers, subprocesses)
63
+ @breaker_reader, @breaker_writer = IO.pipe
64
+ @subprocesses = subprocesses
65
+
66
+ readers = readers.dup
67
+ readers[@breaker_reader] = nil
68
+ super(readers, writers)
69
+
70
+ register_subprocesses
71
+ end
72
+
73
+ def register_subprocesses
74
+ self.class.register_parallel_io(self, @breaker_writer)
75
+ end
76
+
77
+ def run_once(timeout=nil)
78
+ @subprocesses.each do |subprocess|
79
+ subprocess.wait(true)
80
+ end
81
+
82
+ # All subprocesses have exited! We're done here.
83
+ if @subprocesses.all?(&:status)
84
+ finalize_all
85
+ return
86
+ end
87
+
88
+ super
89
+ end
90
+
91
+ def finalize_all
92
+ @breaker_writer.close
93
+
94
+ # We're guaranteed that if a process exited, all of its bytes
95
+ # are immediately available to us.
96
+ consume_all_available
97
+
98
+ available_readers.each {|reader| reader.close}
99
+ available_writers.each {|writer| writer.close}
100
+ self.class.deregister_parallel_io(self)
101
+ end
102
+ end
103
+ end
@@ -4,6 +4,7 @@ require 'timeout'
4
4
  require 'thread'
5
5
 
6
6
  require 'rubysh/subprocess/parallel_io'
7
+ require 'rubysh/subprocess/pid_aware_parallel_io'
7
8
  require 'rubysh/subprocess/pipe_wrapper'
8
9
 
9
10
  module Rubysh
@@ -73,7 +74,13 @@ module Rubysh
73
74
 
74
75
  def do_wait(nonblock=false)
75
76
  flags = nonblock ? Process::WNOHANG : nil
76
- return nil unless result = Process.waitpid2(@pid, flags)
77
+ begin
78
+ result = Process.waitpid2(@pid, flags)
79
+ rescue Errno::ECHILD => e
80
+ raise Rubysh::Error::ECHILDError.new("No unreaped process #{@pid}. This could indicate a bug in Rubysh, but more likely means you have something in your codebase which is wait(2)ing on subprocesses.")
81
+ end
82
+
83
+ return unless result
77
84
 
78
85
  pid, @status = result
79
86
  Rubysh.assert(pid == @pid,
@@ -3,9 +3,12 @@ require 'tempfile'
3
3
  module Rubysh
4
4
  # Looks like bash always buffers <<< to disk
5
5
  class TripleLessThan < BaseDirective
6
+ attr_reader :fd, :literal
7
+
6
8
  class Shell < BaseDirective
7
- def initialize(fd)
9
+ def initialize(fd, opts)
8
10
  @fd = fd
11
+ @opts = opts
9
12
  end
10
13
 
11
14
  def <(literal=:stdin)
@@ -1,3 +1,3 @@
1
1
  module Rubysh
2
- VERSION = '0.0.4'
2
+ VERSION = '0.0.5'
3
3
  end
data/lib/rubysh.rb CHANGED
@@ -94,8 +94,13 @@ module Rubysh
94
94
  command.run(&blk)
95
95
  end
96
96
 
97
+ def self.run_async(*args, &blk)
98
+ command = Rubysh::Command.new(args)
99
+ command.run_async(&blk)
100
+ end
101
+
97
102
  def self.check_call(*args, &blk)
98
- command = Rubysh::Command.new(*args)
103
+ command = Rubysh::Command.new(args)
99
104
  command.check_call(&blk)
100
105
  end
101
106
 
@@ -123,22 +128,47 @@ module Rubysh
123
128
  FD.new(2)
124
129
  end
125
130
 
126
- def self.>(target=:stdout)
127
- Redirect.new(1, '>', target)
131
+ def self.>(target=nil, opts=nil)
132
+ # Might want to DRY this logic up at some point. Right now seems
133
+ # like it'd just sacrifice clarity though.
134
+ if !opts && target.kind_of?(Hash)
135
+ opts = target
136
+ target = nil
137
+ end
138
+ target = :stdout
139
+
140
+ Redirect.new(1, '>', target, opts)
128
141
  end
129
142
 
130
- def self.>>(target=:stdout)
131
- Redirect.new(1, '>>', target)
143
+ def self.>>(target=nil, opts=nil)
144
+ if !opts && target.kind_of?(Hash)
145
+ opts = target
146
+ target = nil
147
+ end
148
+ target = :stdout
149
+
150
+ Redirect.new(1, '>>', target, opts)
132
151
  end
133
152
 
134
- def self.<(target=:stdin)
135
- Redirect.new(0, '<', target)
153
+ def self.<(target=nil, opts=nil)
154
+ if !opts && target.kind_of?(Hash)
155
+ opts = target
156
+ target = nil
157
+ end
158
+ target = :stdin
159
+
160
+ Redirect.new(0, '<', target, opts)
136
161
  end
137
162
 
138
163
  # Hack to implement <<<
139
- def self.<<(fd=nil)
164
+ def self.<<(fd=nil, opts=nil)
165
+ if !opts && fd.kind_of?(Hash)
166
+ opts = fd
167
+ fd = nil
168
+ end
169
+
140
170
  fd ||= FD.new(0)
141
- TripleLessThan::Shell.new(fd)
171
+ TripleLessThan::Shell.new(fd, opts)
142
172
  end
143
173
 
144
174
  # Internal utility methods
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubysh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-02-12 00:00:00.000000000 Z
12
+ date: 2013-08-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: minitest
@@ -68,6 +68,7 @@ files:
68
68
  - lib/rubysh/runner.rb
69
69
  - lib/rubysh/subprocess.rb
70
70
  - lib/rubysh/subprocess/parallel_io.rb
71
+ - lib/rubysh/subprocess/pid_aware_parallel_io.rb
71
72
  - lib/rubysh/subprocess/pipe_wrapper.rb
72
73
  - lib/rubysh/triple_less_than.rb
73
74
  - lib/rubysh/util.rb