rubysh 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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