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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/exeggutor.rb +176 -77
  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: 8d90faa7fb5d339db88d49c085d3a878d92f274595ea95e5a0efead535b3ad48
4
+ data.tar.gz: 7291380ab60f90b974af5e7f32127d1cb45e567bb17455e9978e31ce166fe744
5
5
  SHA512:
6
- metadata.gz: a681829d986c9466675547079d53d90420e9a26b1cf71ce7987a48c766e3400510009595e6e8dec6c73e15b20c2a618e3233367c47827ecfa53ebe8b040a6149
7
- data.tar.gz: '0329cd3a8c536474c5a2090c7a1f049c01c571b9d6038fe013fe9946277884587fef568839f70303c813869c05a510da0f323eeb24ce1b1dbdceefa101dc1e67'
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 [ProcessResult] The result of the process execution.
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
- 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
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
- stderr_thread = Thread.new do
80
- while (line = stderr_stream.gets)
81
- stderr_str << line
82
- warn line if show_stderr
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
- # 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
- 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
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 can_fail [Boolean] If false, raises a ProcessError on failure.
121
- # @param show_stdout [Boolean] If true, prints stdout to the console in real-time.
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 [ProcessResult] An object containing process info such as stdout, stderr, and exit code. Waits for the command to complete to return.
129
- #
130
- # @raise [ProcessError] If the command fails and `can_fail` is false.
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.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-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