rubysh 0.0.1

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.
@@ -0,0 +1,156 @@
1
+ module Rubysh
2
+ class Runner
3
+ attr_accessor :command, :targets
4
+
5
+ def initialize(command)
6
+ @runner_state = :initialized
7
+
8
+ @command = command
9
+ @targets = {}
10
+ @state = {}
11
+
12
+ @parallel_io = nil
13
+
14
+ prepare!
15
+ end
16
+
17
+ def data(target_name)
18
+ state = target_state(target_name)
19
+ raise Rubysh::Error::BaseError.new("Can only access data for readable FDs") unless state[:target_reading?]
20
+ state[:buffer].join
21
+ end
22
+
23
+ # Ruby's Process::Status. Has fun things like pid and signaled?
24
+ def full_status(command=nil)
25
+ command ||= @command
26
+ @command.status(self)
27
+ end
28
+
29
+ def pid(command=nil)
30
+ command ||= @command
31
+ @command.pid(self)
32
+ end
33
+
34
+ # Convenience wrapper
35
+ def exitstatus(command=nil)
36
+ if st = full_status(command)
37
+ st.exitstatus
38
+ else
39
+ nil
40
+ end
41
+ end
42
+
43
+ # API for running/waiting
44
+ def run_async
45
+ 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
46
+ prepare_io
47
+ @command.start_async(self)
48
+ @runner_state = :started
49
+ self
50
+ end
51
+
52
+ def wait
53
+ run_io
54
+ do_wait
55
+ end
56
+
57
+ def run(input={})
58
+ run_async
59
+ run_io
60
+ do_wait
61
+ end
62
+
63
+ def readers
64
+ readers = {}
65
+ @targets.each do |target_name, target_state|
66
+ next unless target_state[:target_reading?]
67
+ target = target_state[:target]
68
+ readers[target] = target_name
69
+ end
70
+ readers
71
+ end
72
+
73
+ def writers
74
+ writers = {}
75
+ @targets.each do |target_name, target_state|
76
+ next if target_state[:target_reading?]
77
+ target = target_state[:target]
78
+ writers[target] = target_name
79
+ end
80
+ writers
81
+ end
82
+
83
+ def to_s
84
+ inspect
85
+ end
86
+
87
+ def inspect
88
+ extras = []
89
+ valid_readers = readers.values.map(&:inspect).join(', ')
90
+ valid_writers = readers.values.map(&:inspect).join(', ')
91
+
92
+ extras << "readers: #{valid_readers}" if valid_readers.length > 0
93
+ extras << "writers: #{valid_writers}" if valid_writers.length > 0
94
+ if status = exitstatus
95
+ extras << "exitstatus: #{status}"
96
+ elsif mypid = pid
97
+ extras << "pid: #{pid}"
98
+ end
99
+ extra_display = extras.length > 0 ? " (#{extras.join(', ')})" : nil
100
+
101
+ "#{self.class}: #{command.stringify}#{extra_display}"
102
+ end
103
+
104
+ # Internal helpers
105
+ def state(object)
106
+ @state[object] ||= {}
107
+ end
108
+
109
+ # Internal helpers
110
+ def target_state(target_name)
111
+ @targets[target_name] || raise(Rubysh::Error::BaseError.new("Invalid target: #{target_name.inspect} (valid targets are: #{@targets.keys.inspect})"))
112
+ end
113
+
114
+ private
115
+
116
+ def do_wait
117
+ raise Rubysh::Error::AlreadyRunError.new("You must run parallel io before waiting. (Perhaps you want to use the 'run' method, which takes care of the plumbing for you?)") unless @runner_state == :parallel_io_ran
118
+ @command.wait(self)
119
+ @runner_state = :waited
120
+ self
121
+ end
122
+
123
+ def run_io
124
+ raise Rubysh::Error::AlreadyRunError.new("You must start the subprocesses before running parallel io. (Perhaps you want to use the 'run' method, which takes care of the plumbing for you?)") unless @runner_state == :started
125
+ @parallel_io.run
126
+ @runner_state = :parallel_io_ran
127
+ self
128
+ end
129
+
130
+ def prepare!
131
+ @command.prepare!(self)
132
+ end
133
+
134
+ # Can't build this in the prepare stage because pipes aren't built
135
+ # there.
136
+ def prepare_io
137
+ @parallel_io = Subprocess::ParallelIO.new(readers, writers)
138
+ @parallel_io.on_read do |target_name, data|
139
+ if data == Subprocess::ParallelIO::EOF
140
+ Rubysh.log.debug("EOF reached on #{target_name.inspect}")
141
+ else
142
+ Rubysh.log.debug("Just read #{data.inspect} on #{target_name.inspect}")
143
+ @targets[target_name][:buffer] << data
144
+ end
145
+ end
146
+
147
+ @parallel_io.on_write do |target_name, written, remaining|
148
+ if data == Subprocess::ParallelIO::EOF
149
+ Rubysh.log.debug("EOF reached on #{target_name.inspect}")
150
+ else
151
+ Rubysh.log.debug("Just wrote #{written.inspect} on #{target_name.inspect}")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,184 @@
1
+ class Rubysh::Subprocess
2
+ class ParallelIO
3
+ module EOF; end
4
+
5
+ # readers/writers should be hashes mapping {fd => name}
6
+ def initialize(readers, writers)
7
+ @finished_readers = Set.new
8
+ @on_read = nil
9
+ @readers = readers
10
+
11
+ @writers = writers
12
+ @finished_writers = Set.new
13
+ @on_write = nil
14
+ @writer_buffers = {}
15
+ end
16
+
17
+ def on_read(&blk)
18
+ @on_read = blk
19
+ end
20
+
21
+ def on_write(&blk)
22
+ @on_write = blk
23
+ end
24
+
25
+ def write(writer_name, data, close_on_complete=true)
26
+ writer = writer_by_name(writer_name)
27
+ buffer_state = @writer_buffers[writer] ||= {
28
+ :data => '',
29
+ :close_on_complete => nil
30
+ }
31
+
32
+ if buffer_state[:close_on_complete]
33
+ raise Rubysh::Error::AlreadyClosedError.new("You have already marked #{writer.inspect} as close_on_complete; can't write more data")
34
+ end
35
+
36
+ buffer_state[:close_on_complete] = close_on_complete
37
+ # XXX: unnecessary copy here
38
+ buffer_state[:data] += data
39
+
40
+ # Note that this leads to a bit of weird semantics if you try
41
+ # doing a write('') from within an on_write handler, since it'll
42
+ # call this synchronously. May want to change at some point.
43
+ finalize_writer_if_done(writer)
44
+ end
45
+
46
+ def close(writer_name)
47
+ writer = writer_by_name(writer_name)
48
+ writer.close
49
+ end
50
+
51
+ def available_readers
52
+ potential = @readers.keys - @finished_readers.to_a
53
+ potential.select {|reader| !reader.closed?}
54
+ end
55
+
56
+ # Writers with a non-zero number of bytes remaining to write
57
+ def available_writers
58
+ potential = @writer_buffers.keys - @finished_writers.to_a
59
+ potential.select {|writer| !writer.closed? && get_data(writer).length > 0}
60
+ end
61
+
62
+ def run
63
+ while available_writers.length > 0 || available_readers.length > 0
64
+ run_once
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def run_once
71
+ potential_readers = available_readers
72
+ potential_writers = available_writers
73
+
74
+ begin
75
+ ready_readers, ready_writers, _ = IO.select(potential_readers, potential_writers)
76
+ rescue Errno::EINTR
77
+ next
78
+ end
79
+
80
+ ready_readers.each do |reader|
81
+ read_available(reader)
82
+ end
83
+
84
+ ready_writers.each do |writer|
85
+ write_available(writer)
86
+ end
87
+ end
88
+
89
+ def read_available(reader)
90
+ begin
91
+ data = reader.read_nonblock(4096)
92
+ rescue EOFError, Errno::EPIPE
93
+ finalize_reader(reader)
94
+ rescue Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::EINTR
95
+ else
96
+ issue_reader_callback(reader, data)
97
+ end
98
+ end
99
+
100
+ def finalize_reader(reader)
101
+ @finished_readers.add(reader)
102
+ issue_reader_callback(reader, EOF)
103
+ reader.close
104
+ end
105
+
106
+ def issue_reader_callback(reader, data)
107
+ if @on_read
108
+ name = reader_name(reader)
109
+ @on_read.call(name, data)
110
+ end
111
+ end
112
+
113
+ def reader_name(reader)
114
+ @readers.fetch(reader)
115
+ end
116
+
117
+ def write_available(writer)
118
+ data = get_data(writer)
119
+ begin
120
+ count = writer.write_nonblock(data)
121
+ rescue EOFError, Errno::EPIPE
122
+ finalize_writer(writer)
123
+ rescue Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::EINTR
124
+ else
125
+ # XXX: This may be a lot of copying. May want to think about
126
+ # how this scales.
127
+ written = data[0...count]
128
+ remaining = data[count..-1]
129
+ set_data(writer, remaining)
130
+ issue_writer_callback(writer, written, remaining)
131
+ end
132
+ finalize_writer_if_done(writer)
133
+ end
134
+
135
+ # Will only schedule a writer if it has a nonzero number of bytes
136
+ # left to write, so we need to manually check if we're out after
137
+ # every run.
138
+ def finalize_writer_if_done(writer)
139
+ if !writer.closed? &&
140
+ buffer_state(writer)[:close_on_complete] &&
141
+ get_data(writer).length == 0
142
+ finalize_writer(writer)
143
+ end
144
+ end
145
+
146
+ def finalize_writer(writer)
147
+ # TODO: think about how we should deal with errors, maybe
148
+ remaining = get_data(writer)
149
+ @finished_writers.add(writer)
150
+ issue_writer_callback(writer, EOF, remaining)
151
+ writer.close if buffer_state(writer)[:close_on_complete]
152
+ end
153
+
154
+ def get_data(writer)
155
+ buffer_state(writer)[:data]
156
+ end
157
+
158
+ def set_data(writer, data)
159
+ buffer_state(writer)[:data] = data
160
+ end
161
+
162
+ def buffer_state(writer)
163
+ buffer_state = @writer_buffers[writer]
164
+ Rubysh.assert(buffer_state, "No buffer state: #{writer.inspect}", true)
165
+ buffer_state
166
+ end
167
+
168
+ def issue_writer_callback(writer, data, remaining)
169
+ if @on_write
170
+ name = writer_name(writer)
171
+ @on_write.call(name, data, remaining)
172
+ end
173
+ end
174
+
175
+ def writer_name(writer)
176
+ @writers.fetch(writer)
177
+ end
178
+
179
+ # Could make this fast, but don't think it matters enough.
180
+ def writer_by_name(writer_name)
181
+ @writers.detect {|writer, name| writer_name == name}.first
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,61 @@
1
+ class Rubysh::Subprocess
2
+ class PipeWrapper
3
+ attr_accessor :reader, :writer
4
+
5
+ def initialize(reader_cloexec=true, writer_cloexec=true)
6
+ @reader, @writer = IO.pipe
7
+ set_reader_cloexec if reader_cloexec
8
+ set_writer_cloexec if writer_cloexec
9
+ end
10
+
11
+ def read_only
12
+ @writer.close
13
+ end
14
+
15
+ def write_only
16
+ @reader.close
17
+ end
18
+
19
+ def close
20
+ @writer.close
21
+ @reader.close
22
+ end
23
+
24
+ def set_reader_cloexec
25
+ @reader.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
26
+ end
27
+
28
+ def set_writer_cloexec
29
+ @writer.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
30
+ end
31
+
32
+ def nonblock
33
+ [@reader, @writer].each do |fd|
34
+ fl = fd.fcntl(Fcntl::F_GETFL)
35
+ fd.fcntl(Fcntl::F_SETFL, fl | Fcntl::O_NONBLOCK)
36
+ end
37
+ end
38
+
39
+ def dump_yaml_and_close(msg)
40
+ begin
41
+ YAML.dump(msg, @writer)
42
+ ensure
43
+ @writer.close
44
+ Rubysh.assert(@reader.closed?, "Reader should already be closed")
45
+ end
46
+ end
47
+
48
+ def load_yaml_and_close
49
+ begin
50
+ YAML.load(@reader)
51
+ rescue ArgumentError => e
52
+ # e.g. ArgumentError: syntax error on line 0, col 2: `' (could
53
+ # happen if the subprocess was killed while writing a message)
54
+ raise Rubysh::Error::BaseError.new("Invalid message read from pipe: #{e}")
55
+ ensure
56
+ @reader.close
57
+ Rubysh.assert(@writer.closed?, "Writer should already be closed")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,154 @@
1
+ # Adapted from https://github.com/ahoward/open4
2
+ require 'fcntl'
3
+ require 'timeout'
4
+ require 'thread'
5
+
6
+ # Using YAML to avoid the JSON dep. open4 uses Marshal to pass around
7
+ # the exception object, but I'm always a bit sketched by Marshal when
8
+ # it's not needed (i.e. don't want the subprocess to have the ability
9
+ # to execute code in the parent, even if it should lose that ability
10
+ # post-exec.)
11
+ require 'yaml'
12
+
13
+ require 'rubysh/subprocess/parallel_io'
14
+ require 'rubysh/subprocess/pipe_wrapper'
15
+
16
+ module Rubysh
17
+ class Subprocess
18
+ attr_accessor :command, :args, :directives, :runner
19
+ attr_accessor :pid, :status, :exec_error
20
+
21
+ # TODO: switch directives over to an OrderedHash of some form? Really
22
+ # want to preserve the semantics here.
23
+ def initialize(args, directives=[], post_fork=[], runner=nil)
24
+ raise ArgumentError.new("Must provide an array (#{args.inspect} provided)") unless args.kind_of?(Array)
25
+ raise ArgumentError.new("No command specified (#{args.inspect} provided)") unless args.length > 0
26
+ @command = args[0]
27
+ @args = args[1..-1]
28
+ @directives = directives
29
+ @runner = runner
30
+
31
+ Rubysh.assert(@directives.length == 0 || @runner, "Directives provided but no runner is", true)
32
+
33
+ @exec_status = nil
34
+ @post_fork = post_fork
35
+
36
+ @pid = nil
37
+ @status = nil
38
+ @exec_error = nil
39
+
40
+ Rubysh.log.debug("Just created: #{self}")
41
+ end
42
+
43
+ def to_s
44
+ "Subprocess: command=#{@command.inspect} args=#{@args.inspect} directives: #{@directives.inspect}"
45
+ end
46
+
47
+ def run
48
+ do_run unless @pid
49
+ @pid
50
+ end
51
+
52
+ def wait(nonblock=false)
53
+ do_wait(nonblock) unless @status
54
+ @status
55
+ end
56
+
57
+ private
58
+
59
+ def do_run
60
+ # Create this here so as to not leave an open pipe hanging
61
+ # around for too long. Not sure what would happen if a child
62
+ # inherited it.
63
+ open_exec_status
64
+ @pid = fork do
65
+ do_run_child
66
+ end
67
+ do_run_parent
68
+ end
69
+
70
+ def open_exec_status
71
+ @exec_status = PipeWrapper.new
72
+ end
73
+
74
+ def do_run_parent
75
+ # nil in tests
76
+ @exec_status.read_only
77
+ apply_directives_parent
78
+ handle_exec_error
79
+ end
80
+
81
+ def do_wait(nonblock=false)
82
+ flags = nonblock ? Process::WNOHANG : nil
83
+ return nil unless result = Process.waitpid2(@pid, flags)
84
+
85
+ pid, @status = result
86
+ Rubysh.assert(pid == @pid,
87
+ "Process.waitpid2 returned #{pid} while waiting for #{@pid}",
88
+ true)
89
+ end
90
+
91
+ def do_run_child
92
+ # nil in tests
93
+ @exec_status.write_only
94
+ run_post_fork
95
+ apply_directives_child
96
+ exec_program
97
+ end
98
+
99
+ def run_post_fork
100
+ @post_fork.each {|blk| blk.call}
101
+ end
102
+
103
+ def apply_directives_parent
104
+ apply_directives(true)
105
+ end
106
+
107
+ def apply_directives_child
108
+ apply_directives(false)
109
+ end
110
+
111
+ def apply_directives(is_parent)
112
+ @directives.each {|directive| apply_directive(directive, is_parent)}
113
+ end
114
+
115
+ def apply_directive(directive, is_parent)
116
+ if is_parent
117
+ directive.apply_parent!(runner)
118
+ else
119
+ directive.apply!(runner)
120
+ end
121
+ end
122
+
123
+ def exec_program
124
+ begin
125
+ Kernel.exec([command, command], *args)
126
+ raise Rubysh::Error::UnreachableError.new("This code should be unreachable. If you are seeing this exception, it means someone overrode Kernel.exec. That's not very nice of them.")
127
+ rescue Exception => e
128
+ msg = {
129
+ 'message' => e.message,
130
+ 'klass' => e.class.to_s,
131
+ # TODO: this may need coercion in Ruby1.9
132
+ 'caller' => e.send(:caller)
133
+ }
134
+ @exec_status.dump_yaml_and_close(msg)
135
+ # Note: atexit handlers will fire in this case. May want to do
136
+ # something about that.
137
+ raise
138
+ end
139
+ end
140
+
141
+ def handle_exec_error
142
+ msg = @exec_status.load_yaml_and_close
143
+
144
+ case msg
145
+ when false
146
+ # success!
147
+ when Hash
148
+ @exec_error = Rubysh::Error::ExecError.new("Failed to exec in subprocess: #{msg['message']}", msg['klass'], msg['caller'])
149
+ else
150
+ @exec_error = Rubysh::Error::BaseError.new("Invalid message received over the exec_status pipe: #{msg.inspect}")
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,65 @@
1
+ require 'tempfile'
2
+
3
+ module Rubysh
4
+ # Looks like bash always buffers <<< to disk
5
+ class TripleLessThan < BaseDirective
6
+ class Shell < BaseDirective
7
+ def initialize(fd)
8
+ @fd = fd
9
+ end
10
+
11
+ def <(literal)
12
+ TripleLessThan.new(@fd, literal)
13
+ end
14
+
15
+ def prepare!
16
+ raise Rubysh::Error::BaseError.new("You have an incorrect usage of <<<, leading to a #{self.class} instance hanging around. Use it as either: Rubysh.<<< 'my string' or Rubysh::FD(3).<<< 'my string'.")
17
+ end
18
+
19
+ def stringify
20
+ " << #{fd.stringify} (INVALID SYNTAX)"
21
+ end
22
+ end
23
+
24
+ # TODO: support in-place strings
25
+ def initialize(fd, literal)
26
+ @fd = fd
27
+ @literal = literal
28
+ end
29
+
30
+ def prepare!(runner)
31
+ tempfile = Tempfile.new('buffer')
32
+ tempfile.delete
33
+ tempfile.write(@literal)
34
+ tempfile.flush
35
+ tempfile.rewind
36
+
37
+ Util.set_cloexec(tempfile)
38
+
39
+ state = state(runner)
40
+ state[:tempfile] = tempfile
41
+ state[:redirect] = Redirect.new(@fd, '<', tempfile)
42
+ end
43
+
44
+ def stringify
45
+ fd = Util.to_fileno(@fd)
46
+ beginning = fd == 0 ? '' : fd.to_s
47
+ "#{beginning}<<< #{@literal.inspect}"
48
+ end
49
+
50
+ def to_s
51
+ "TripleLessThan: #{stringify}"
52
+ end
53
+
54
+ def apply_parent!(runner)
55
+ state = state(runner)
56
+ state[:tempfile].close
57
+ state[:redirect].apply_parent!(runner)
58
+ end
59
+
60
+ def apply!(runner)
61
+ state = state(runner)
62
+ state[:redirect].apply!(runner)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,55 @@
1
+ require 'set'
2
+
3
+ module Rubysh
4
+ module Util
5
+ def self.to_fileno(file)
6
+ if file.respond_to?(:fileno)
7
+ file.fileno
8
+ else
9
+ file
10
+ end
11
+ end
12
+
13
+ # Leaks memory (needed to avoid Ruby 1.8's IO autoclose behavior),
14
+ # and so you should only use it right before execing.
15
+ def self.io_without_autoclose(fd_num)
16
+ fd_num = to_fileno(fd_num)
17
+ io = IO.new(fd_num)
18
+ hold(io)
19
+ io
20
+ end
21
+
22
+ # Should really just shell out to dup2, but looks like we'd need a
23
+ # C extension to do so. The concurrency story here is a bit off,
24
+ # and this probably doesn't copy over all FD state
25
+ # properly. Should be fine for now.
26
+ def self.dup2(fildes, fildes2)
27
+ original = io_without_autoclose(fildes)
28
+
29
+ begin
30
+ copy = io_without_autoclose(fildes2)
31
+ rescue Errno::EBADF
32
+ else
33
+ copy.close
34
+ end
35
+
36
+ res = original.fcntl(Fcntl::F_DUPFD, fildes2)
37
+ Rubysh.assert(res == fildes2, "Tried to open #{fildes2} but ended up with #{res} instead", true)
38
+ end
39
+
40
+ def self.set_cloexec(file, enable=true)
41
+ file = io_without_autoclose(file) unless file.kind_of?(IO)
42
+ value = enable ? Fcntl::FD_CLOEXEC : 0
43
+ file.fcntl(Fcntl::F_SETFD, value)
44
+ end
45
+
46
+ private
47
+
48
+ @references = []
49
+ def self.hold(*references)
50
+ # Needed for Ruby 1.8, where we can't set IO objects to not
51
+ # close the underlying FD on destruction
52
+ @references += references
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module Rubysh
2
+ VERSION = "0.0.1"
3
+ end