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