exeggutor 0.1.0 → 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 +173 -65
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b9eb0de91468b1ba1d04da72cac44b57eaadd3553719d86910d71f88af67746
4
- data.tar.gz: 77713dec1ae04c17322d25563abf517dc406c34f61661e1fd483abf9a307f123
3
+ metadata.gz: f12efae289d93904fde9c9f64edda09e2b085805969648d3345c4054597032fc
4
+ data.tar.gz: 35d7e49dd376fc0a03dc70c02b61c947bbbc0d27c0ba31c16250b4bce9332b74
5
5
  SHA512:
6
- metadata.gz: edfbd0cbe10c2b38812b8a92adfa91566cdd5f4823a4d4e9030a516c7b0c352c465796c7c30b78744db33501d3cf832acf1570076aa47a1db0826621a5d0ab16
7
- data.tar.gz: a7bdcd0f7f915dfce050b7cb84ec3ffe92ac46304524e2f51440eeef02417209458f35562256b7e74807795501d893a24eed97cf947481686d5f7bd3a1c7e064
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,83 +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)
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
43
139
  if env
44
- Open3.popen3(env, [args[0], args[0]], *args.drop(1))
140
+ Open3.popen3(env, [args[0], args[0]], *args.drop(1), opts)
45
141
  else
46
- Open3.popen3([args[0], args[0]], *args.drop(1))
142
+ Open3.popen3([args[0], args[0]], *args.drop(1), opts)
47
143
  end
48
144
  end
49
145
 
50
- # Executes a command with the provided arguments and options
51
- #
52
- # @param args [Array<String>] The command and its arguments as an array.
53
- # @param can_fail [Boolean] If false, raises a ProcessError on failure.
54
- # @param show_stdout [Boolean] If true, prints stdout to the console in real-time.
55
- # @param show_stderr [Boolean] If true, prints stderr to the console in real-time.
56
- # @param cwd [String, nil] The working directory to run the command in. If nil, uses the current working directory.
57
- # @param stdin_data [String, nil] Input data to pass to the command's stdin. If nil, doesn't pass any data to stdin.
58
- # @param env_vars [Hash{String => String}, nil] A hashmap containing environment variable overrides,
59
- # or `nil` if no overrides are desired
60
- #
61
- # @return [ProcessResult] An object containing process info such as stdout, stderr, and exit code. Waits for the command to complete to return.
62
- #
63
- # @raise [ProcessError] If the command fails and `can_fail` is false.
64
- def self.run!(args, can_fail: false, show_stdout: false, show_stderr: false, env: nil, cwd: nil, stdin_data: nil)
65
- # TODO: expand "~"? popen3 doesn't expand it by default
66
- if cwd
67
- stdin_stream, stdout_stream, stderr_stream, wait_thread = Dir.chdir(cwd) { Exeggutor::run_popen3(args, env) }
68
- else
69
- stdin_stream, stdout_stream, stderr_stream, wait_thread = Exeggutor::run_popen3(args, env)
70
- end
71
-
72
- stdin_stream.write(stdin_data) if stdin_data
73
- stdin_stream.close
74
-
75
- stderr_stream.sync = true # Match terminals more closely
76
-
77
- stdout_str = +'' # Using unfrozen string
78
- stderr_str = +''
79
-
80
- # Start readers for both stdout and stderr
81
- stdout_thread = Thread.new do
82
- while (line = stdout_stream.gets)
83
-
84
- stdout_str << line
85
- print line if show_stdout
86
- end
87
- end
88
-
89
- stderr_thread = Thread.new do
90
- while (line = stderr_stream.gets)
91
- stderr_str << line
92
- warn line if show_stderr
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?
149
+
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
153
+
154
+ # Make the streams as synchronous as possible, to minimize the possibility of a surprising lack
155
+ # of output
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
93
183
  end
94
184
  end
95
185
 
96
- # Wait for process completion
97
- exit_status = wait_thread.value
98
-
99
- # Ensure all IO is complete
100
- stdout_thread.join
101
- stderr_thread.join
102
-
103
- # Close open pipes
104
- stdout_stream.close
105
- stderr_stream.close
106
-
107
186
  result = ProcessResult.new(
108
- stdout: stdout_str.force_encoding('UTF-8'),
109
- stderr: stderr_str.force_encoding('UTF-8'),
110
- exit_code: exit_status.exitstatus
187
+ stdout: stdout,
188
+ stderr: stderr,
189
+ exit_code: wait_thr.value.exitstatus,
190
+ pid: wait_thr.pid
111
191
  )
112
-
113
192
  if !can_fail && !result.success?
114
193
  error_str = <<~ERROR_STR
115
194
  Command failed: #{args.shelljoin}
116
195
  Exit code: #{result.exit_code}
117
196
  stdout: #{result.stdout}
118
197
  stderr: #{result.stderr}
198
+ pid: #{result.pid}
119
199
  ERROR_STR
120
200
  raise ProcessError.new(result), error_str
121
201
  end
@@ -124,6 +204,34 @@ module Exeggutor
124
204
  end
125
205
  end
126
206
 
127
- def run!(...)
128
- Exeggutor::run!(...)
207
+ # Executes a command with the provided arguments and options. Waits for the process to finish.
208
+ #
209
+ # @param args [Array<String>] The command and its arguments as an array.
210
+ # @param can_fail [Boolean] If false, raises a ProcessError on failure.
211
+ # @param show_stdout [Boolean] If true, prints stdout to the console in real-time.
212
+ # @param show_stderr [Boolean] If true, prints stderr to the console in real-time.
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,
230
+ # or `nil` if no overrides are desired
231
+ #
232
+ # @return [ProcessHandle]
233
+ #
234
+ # @raise [ProcessError] If the command fails and `can_fail` is false.
235
+ def exeg_async(...)
236
+ Exeggutor::ProcessHandle.new(...)
129
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.0
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-23 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