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.
- checksums.yaml +4 -4
- data/lib/exeggutor.rb +173 -65
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f12efae289d93904fde9c9f64edda09e2b085805969648d3345c4054597032fc
|
|
4
|
+
data.tar.gz: 35d7e49dd376fc0a03dc70c02b61c947bbbc0d27c0ba31c16250b4bce9332b74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
109
|
-
stderr:
|
|
110
|
-
exit_code:
|
|
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
|
-
|
|
128
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2025-02-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|