rubysh 0.0.4 → 0.0.5

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.
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