exeggutor 0.1.1 → 0.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/exeggutor.rb +159 -55
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ef72410040189a5e3d92f9112a65ddf167d08196047136da03e5471fbce10c6
4
- data.tar.gz: 2767a58074b611c9b47448313f1eb483648b8b81336fd7e49998cece6ca11ed6
3
+ metadata.gz: f12efae289d93904fde9c9f64edda09e2b085805969648d3345c4054597032fc
4
+ data.tar.gz: 35d7e49dd376fc0a03dc70c02b61c947bbbc0d27c0ba31c16250b4bce9332b74
5
5
  SHA512:
6
- metadata.gz: a681829d986c9466675547079d53d90420e9a26b1cf71ce7987a48c766e3400510009595e6e8dec6c73e15b20c2a618e3233367c47827ecfa53ebe8b040a6149
7
- data.tar.gz: '0329cd3a8c536474c5a2090c7a1f049c01c571b9d6038fe013fe9946277884587fef568839f70303c813869c05a510da0f323eeb24ce1b1dbdceefa101dc1e67'
6
+ metadata.gz: 9d627a0d4a40b7a235643f57a8a67836c9f016ff66c1473da7e629f30cbd2cb084ae91ed9a76fd8ac931f7fd95424331de6ebe8a5f918ac8eba48d425ccf261d
7
+ data.tar.gz: 16e0d72b24bc2b0da0a4522c0e90720eefbcf18c28cdae17daea1d9ad2bdc76880769040517763bdc90c2fd62df85ddbd4a6a183f2c44acadd185fc8f9e7392c
data/lib/exeggutor.rb CHANGED
@@ -2,19 +2,112 @@ require 'open3'
2
2
  require 'shellwords'
3
3
 
4
4
  module Exeggutor
5
+ # A handle to a process, with IO handles to communicate with it
6
+ # and a {ProcessResult} object when it's done. It's largely similar to the array
7
+ # of 4 values return by {Open3.popen3}. However, it doesn't suffer from that library's
8
+ # dead-locking issue. For example, even if lots of data has been written to stdout that hasn't been
9
+ # read, the subprocess can still write to stdout and stderr without blocking
10
+ class ProcessHandle
11
+ # @private
12
+ def initialize(args, env: nil, chdir: nil)
13
+ @stdin_io, @stdout_io, @stderr_io, @wait_thread = Exeggutor::run_popen3(args, env, chdir)
14
+
15
+ # Make the streams as synchronous as possible, to minimize the possibility of a surprising lack
16
+ # of output
17
+ @stdout_io.sync = true
18
+ @stderr_io.sync = true
19
+
20
+ @stdout_queue = Queue.new
21
+ @stderr_queue = Queue.new
22
+
23
+ @stdout_pipe_reader, @stdout_pipe_writer = IO.pipe
24
+ @stderr_pipe_reader, @stderr_pipe_writer = IO.pipe
25
+
26
+ @stdout_write_thread = Thread.new do
27
+ loop do
28
+ data = @stdout_queue.pop
29
+ break if !data # Queue is closed
30
+ @stdout_pipe_writer.write(data)
31
+ end
32
+ @stdout_pipe_writer.close
33
+ end
34
+
35
+ @stderr_write_thread = Thread.new do
36
+ loop do
37
+ data = @stderr_queue.pop
38
+ break if !data # Queue is closed
39
+ @stderr_pipe_writer.write(data)
40
+ end
41
+ @stderr_pipe_writer.close
42
+ end
43
+
44
+ # popen3 can deadlock if one stream is written to too much without being read,
45
+ # so it's important to continuously read from both streams. This is why
46
+ # we can't just let the user call .gets on the streams themselves
47
+ @read_thread = Thread.new do
48
+ remaining_ios = [@stdout_io, @stderr_io]
49
+ while remaining_ios.size > 0
50
+ readable_ios, = IO.select(remaining_ios)
51
+ for readable_io in readable_ios
52
+ begin
53
+ data = readable_io.read_nonblock(100_000)
54
+ if readable_io == @stdout_io
55
+ @stdout_queue.push(data)
56
+ else
57
+ @stderr_queue.push(data)
58
+ end
59
+ rescue IO::WaitReadable
60
+ # Shouldn't usually happen because IO.select indicated data is ready, but maybe due to EINTR or something
61
+ next
62
+ rescue EOFError
63
+ if readable_io == @stdout_io
64
+ @stdout_queue.close
65
+ else
66
+ @stderr_queue.close
67
+ end
68
+ remaining_ios.delete(readable_io)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ # An object containing process metadata and which can be waited on to wait
76
+ # until the subprocess ends. Identical to popen3's wait_thr
77
+ def wait_thr
78
+ @wait_thread
79
+ end
80
+
81
+ # An IO object for stdin that can be written to
82
+ def stdin
83
+ @stdin_io
84
+ end
85
+
86
+ # An IO object for stdout that can be written to
87
+ def stdout
88
+ @stdout_pipe_reader
89
+ end
90
+
91
+ # An IO object for stderr that can be written to
92
+ def stderr
93
+ @stderr_pipe_reader
94
+ end
95
+ end
96
+
5
97
  # Represents the result of a process execution.
6
98
  #
7
99
  # @attr_reader stdout [String] The standard output of the process.
8
100
  # @attr_reader stderr [String] The standard error of the process.
9
101
  # @attr_reader exit_code [Integer] The exit code of the process.
10
102
  class ProcessResult
11
- attr_reader :stdout, :stderr, :exit_code
103
+ attr_reader :stdout, :stderr, :exit_code, :pid
12
104
 
13
105
  # @private
14
- def initialize(stdout:, stderr:, exit_code:)
106
+ def initialize(stdout:, stderr:, exit_code:, pid:)
15
107
  @stdout = stdout
16
108
  @stderr = stderr
17
109
  @exit_code = exit_code
110
+ @pid = pid
18
111
  end
19
112
 
20
113
  # Checks if the process was successful.
@@ -39,73 +132,70 @@ module Exeggutor
39
132
  end
40
133
 
41
134
  # @private
42
- def self.run_popen3(args, env)
135
+ def self.run_popen3(args, env, chdir)
43
136
  # Use this weird [args[0], args[0]] thing for the case where a command with just one arg is being run
137
+ opts = {}
138
+ opts[:chdir] = chdir if chdir
44
139
  if env
45
- Open3.popen3(env, [args[0], args[0]], *args.drop(1))
140
+ Open3.popen3(env, [args[0], args[0]], *args.drop(1), opts)
46
141
  else
47
- Open3.popen3([args[0], args[0]], *args.drop(1))
142
+ Open3.popen3([args[0], args[0]], *args.drop(1), opts)
48
143
  end
49
144
  end
50
145
 
51
- def self.run!(args, can_fail: false, show_stdout: false, show_stderr: false, env: nil, cwd: nil, stdin_data: nil)
52
- # TODO: expand "~"? popen3 doesn't expand it by default
53
- if cwd
54
- stdin_stream, stdout_stream, stderr_stream, wait_thread = Dir.chdir(cwd) { Exeggutor::run_popen3(args, env) }
55
- else
56
- stdin_stream, stdout_stream, stderr_stream, wait_thread = Exeggutor::run_popen3(args, env)
57
- end
146
+ # @private
147
+ def self.exeg(args, can_fail: false, show_stdout: false, show_stderr: false, env: nil, chdir: nil, stdin_data: nil)
148
+ raise "args.size must be >= 1" if args.empty?
58
149
 
59
- stdin_stream.write(stdin_data) if stdin_data
60
- stdin_stream.close
150
+ stdin_io, stdout_io, stderr_io, wait_thr = Exeggutor::run_popen3(args, env, chdir)
151
+ stdin_io.write(stdin_data) if stdin_data
152
+ stdin_io.close
61
153
 
62
154
  # Make the streams as synchronous as possible, to minimize the possibility of a surprising lack
63
155
  # of output
64
- stdout_stream.sync = true
65
- stderr_stream.sync = true
66
-
67
- stdout_str = +'' # Using unfrozen string
68
- stderr_str = +''
69
-
70
- # Start readers for both stdout and stderr
71
- stdout_thread = Thread.new do
72
- while (line = stdout_stream.gets)
73
-
74
- stdout_str << line
75
- print line if show_stdout
76
- end
77
- end
78
-
79
- stderr_thread = Thread.new do
80
- while (line = stderr_stream.gets)
81
- stderr_str << line
82
- warn line if show_stderr
156
+ stdout_io.sync = true
157
+ stderr_io.sync = true
158
+
159
+ stdout = +''
160
+ stderr = +''
161
+
162
+ # Although there could be more code sharing between this and exeg_async, it would either complicate exeg_async's inner workings
163
+ # or force us to pay the same performance cost that exeg_async does
164
+ remaining_ios = [stdout_io, stderr_io]
165
+ while remaining_ios.size > 0
166
+ readable_ios, = IO.select(remaining_ios)
167
+ for readable_io in readable_ios
168
+ begin
169
+ data = readable_io.read_nonblock(100_000)
170
+ if readable_io == stdout_io
171
+ stdout << data
172
+ $stdout.print(data) if show_stdout
173
+ else
174
+ stderr << data
175
+ $stderr.print(data) if show_stderr
176
+ end
177
+ rescue IO::WaitReadable
178
+ # Shouldn't usually happen because IO.select indicated data is ready, but maybe due to EINTR or something
179
+ next
180
+ rescue EOFError
181
+ remaining_ios.delete(readable_io)
182
+ end
83
183
  end
84
184
  end
85
185
 
86
- # Wait for process completion
87
- exit_status = wait_thread.value
88
-
89
- # Ensure all IO is complete
90
- stdout_thread.join
91
- stderr_thread.join
92
-
93
- # Close open pipes
94
- stdout_stream.close
95
- stderr_stream.close
96
-
97
186
  result = ProcessResult.new(
98
- stdout: stdout_str.force_encoding('UTF-8'),
99
- stderr: stderr_str.force_encoding('UTF-8'),
100
- exit_code: exit_status.exitstatus
187
+ stdout: stdout,
188
+ stderr: stderr,
189
+ exit_code: wait_thr.value.exitstatus,
190
+ pid: wait_thr.pid
101
191
  )
102
-
103
192
  if !can_fail && !result.success?
104
193
  error_str = <<~ERROR_STR
105
194
  Command failed: #{args.shelljoin}
106
195
  Exit code: #{result.exit_code}
107
196
  stdout: #{result.stdout}
108
197
  stderr: #{result.stderr}
198
+ pid: #{result.pid}
109
199
  ERROR_STR
110
200
  raise ProcessError.new(result), error_str
111
201
  end
@@ -114,20 +204,34 @@ module Exeggutor
114
204
  end
115
205
  end
116
206
 
117
- # Executes a command with the provided arguments and options
207
+ # Executes a command with the provided arguments and options. Waits for the process to finish.
118
208
  #
119
209
  # @param args [Array<String>] The command and its arguments as an array.
120
210
  # @param can_fail [Boolean] If false, raises a ProcessError on failure.
121
211
  # @param show_stdout [Boolean] If true, prints stdout to the console in real-time.
122
212
  # @param show_stderr [Boolean] If true, prints stderr to the console in real-time.
123
- # @param cwd [String, nil] The working directory to run the command in. If nil, uses the current working directory.
124
- # @param stdin_data [String, nil] Input data to pass to the command's stdin. If nil, doesn't pass any data to stdin.
125
- # @param env_vars [Hash{String => String}, nil] A hashmap containing environment variable overrides,
213
+ # @param chdir [String, nil] The working directory to run the command in. If nil, uses the current working directory.
214
+ # @param stdin [String, nil] Input data to pass to the command's stdin. If nil, doesn't pass any data to stdin.
215
+ # @param env [Hash{String => String}, nil] A hashmap containing environment variable overrides,
216
+ # or `nil` if no overrides are desired
217
+ #
218
+ # @return [ProcessResult] An object containing process info such as stdout, stderr, and exit code.
219
+ #
220
+ # @raise [ProcessError] If the command fails and `can_fail` is false.
221
+ def exeg(...)
222
+ Exeggutor::exeg(...)
223
+ end
224
+
225
+ # Executes a command with the provided arguments and options. Does not wait for the process to finish.
226
+ #
227
+ # @param args [Array<String>] The command and its arguments as an array.
228
+ # @param chdir [String, nil] The working directory to run the command in. If nil, uses the current working directory.
229
+ # @param env [Hash{String => String}, nil] A hashmap containing environment variable overrides,
126
230
  # or `nil` if no overrides are desired
127
231
  #
128
- # @return [ProcessResult] An object containing process info such as stdout, stderr, and exit code. Waits for the command to complete to return.
232
+ # @return [ProcessHandle]
129
233
  #
130
234
  # @raise [ProcessError] If the command fails and `can_fail` is false.
131
- def run!(...)
132
- Exeggutor::run!(...)
235
+ def exeg_async(...)
236
+ Exeggutor::ProcessHandle.new(...)
133
237
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exeggutor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Eisel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-02-25 00:00:00.000000000 Z
11
+ date: 2025-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest