process_executer 0.2.0 → 0.3.0
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/CHANGELOG.md +20 -0
- data/README.md +62 -22
- data/lib/process_executer/monitored_pipe.rb +237 -0
- data/lib/process_executer/options.rb +143 -0
- data/lib/process_executer/process.rb +72 -0
- data/lib/process_executer/status.rb +345 -0
- data/lib/process_executer/version.rb +2 -2
- data/lib/process_executer.rb +42 -199
- data/process_executer.gemspec +1 -0
- metadata +20 -3
- data/lib/process_executer/result.rb +0 -68
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ae4d5881afe09475a15692bf7110746ff4dfd76a70958775351c6fb510a7fb2f
|
4
|
+
data.tar.gz: 683508220b6137f2129bf094b823862ae155a7a5b418aced87dd0056bb0eda7f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 506898e021e1bf3b923d3900fb83005836e729daf064885c0d8172a35690fc9f7865d716197ce60cfb6d6a5153817bde385f6b5b38165296b7f1c213a7a6cfe3
|
7
|
+
data.tar.gz: 3111bfbaf30068de540fb1463a8f862a1d55d6c26f3c47cc90776b2d111a3330601927d4c7e70dcc719ea7c6e60da447c6a2b9b6bddeb89749f44e106a71847e
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,26 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to the process_executer gem will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## v0.3.0 (2022-12-01)
|
9
|
+
|
10
|
+
[Full Changelog](https://github.com/main-branch/process_executer/compare/v0.2.0...v0.3.0)
|
11
|
+
|
12
|
+
* 6e2cdf1 Completely refactor to a single ProcessExecuter.spawn method (#7)
|
13
|
+
* 6da57ec Add CodeClimate badges to README.md (#6)
|
14
|
+
* eebd6ae Add front matter and v0.1.0 release to changelog (#5)
|
15
|
+
* 78cb9e5 Release v0.2.0
|
16
|
+
|
1
17
|
## v0.2.0 (2022-11-16)
|
2
18
|
|
3
19
|
[Full Changelog](https://github.com/main-branch/process_executer/compare/v0.1.0...v0.2.0)
|
4
20
|
|
5
21
|
* 8b70ac0 Use the create_github_release gem to make the release PR (#2)
|
6
22
|
* 4b2700e Add ProcessExecuter#execute to execute a command and return the result (#1)
|
23
|
+
|
24
|
+
## v0.1.0 (2022-10-20)
|
25
|
+
|
26
|
+
Initial release of an empty project
|
data/README.md
CHANGED
@@ -1,6 +1,67 @@
|
|
1
1
|
# The ProcessExecuter Gem
|
2
2
|
|
3
|
-
|
3
|
+
[](https://badge.fury.io/rb/process_executer)
|
4
|
+
[](https://github.com/main-branch/process_executer/actions?query=workflow%3ARuby)
|
5
|
+
[](https://codeclimate.com/github/main-branch/process_executer/maintainability)
|
6
|
+
[](https://codeclimate.com/github/main-branch/process_executer/test_coverage)
|
7
|
+
|
8
|
+
## Features
|
9
|
+
|
10
|
+
This gem contains the following features:
|
11
|
+
|
12
|
+
### ProcessExecuter::MonitoredPipe
|
13
|
+
|
14
|
+
`ProcessExecuter::MonitoredPipe` streams data sent through a pipe to one or more writers.
|
15
|
+
|
16
|
+
When a new `MonitoredPipe` is created, an pipe is created (via IO.pipe) and
|
17
|
+
a thread is created which reads data as it is written written to the pipe.
|
18
|
+
|
19
|
+
Data that is read from the pipe is written one or more writers passed to
|
20
|
+
`MonitoredPipe#initialize`.
|
21
|
+
|
22
|
+
This is useful for streaming process output (stdout and/or stderr) to anything that has a
|
23
|
+
`#write` method: a string buffer, a file, or stdout/stderr as seen in the following example:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
require 'stringio'
|
27
|
+
require 'process_executer'
|
28
|
+
|
29
|
+
output_buffer = StringIO.new
|
30
|
+
out_pipe = ProcessExecuter::MonitoredPipe.new(output_buffer)
|
31
|
+
pid, status = Process.wait2(Process.spawn('echo "Hello World"', out: out_pipe))
|
32
|
+
output_buffer.string #=> "Hello World\n"
|
33
|
+
```
|
34
|
+
|
35
|
+
`MonitoredPipe#initialize` can take more than one writer so that pipe output can be
|
36
|
+
streamed (or `tee`d) to multiple writers at the same time:
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
require 'stringio'
|
40
|
+
require 'process_executer'
|
41
|
+
|
42
|
+
output_buffer = StringIO.new
|
43
|
+
output_file = File.open('process.out', 'w')
|
44
|
+
out_pipe = ProcessExecuter::MonitoredPipe.new(output_buffer, output_file)
|
45
|
+
pid, status = Process.wait2(Process.spawn('echo "Hello World"', out: out_pipe))
|
46
|
+
output_file.close
|
47
|
+
output_buffer.string #=> "Hello World\n"
|
48
|
+
File.read('process.out') #=> "Hello World\n"
|
49
|
+
```
|
50
|
+
|
51
|
+
Since the data is streamed, any object that implements `#write` can be used. For insance,
|
52
|
+
you can use it to parse process output as a stream which might be useful for long XML
|
53
|
+
or JSON output.
|
54
|
+
|
55
|
+
### ProcessExecuter.spawn
|
56
|
+
|
57
|
+
`ProcessExecuter.spawn` has the same interface as `Process.spawn` but has two
|
58
|
+
important behaviorial differences:
|
59
|
+
|
60
|
+
1. It blocks until the subprocess finishes
|
61
|
+
2. A timeout can be specified using the `:timeout` option
|
62
|
+
|
63
|
+
If the command does not terminate before the timeout, the process is killed by
|
64
|
+
sending it the SIGKILL signal.
|
4
65
|
|
5
66
|
## Installation
|
6
67
|
|
@@ -37,27 +98,6 @@ commits and the created tag, and push the `.gem` file to
|
|
37
98
|
Bug reports and pull requests are welcome on our
|
38
99
|
[GitHub issue tracker](https://github.com/main-branch/process_executer)
|
39
100
|
|
40
|
-
## Feature Checklist
|
41
|
-
|
42
|
-
Here is the 1.0 feature checklist:
|
43
|
-
|
44
|
-
* [x] Run a command
|
45
|
-
* [x] Collect the command's stdout/stderr to a string
|
46
|
-
* [x] Passthru the command's stdout/stderr to this process's stdout/stderr
|
47
|
-
* [ ] Command execution timeout
|
48
|
-
* [ ] Redirect stdout/stderr to a named file
|
49
|
-
* [ ] Redirect stdout/stderr to a named file with open mode
|
50
|
-
* [ ] Redirect stdout/stderr to a named file with open mode and permissions
|
51
|
-
* [ ] Redirect stdout/stderr to an open File object
|
52
|
-
* [ ] Merge stdout & stderr
|
53
|
-
* [ ] Redirect a file to stdin
|
54
|
-
* [ ] Redirect from a butter to stdin
|
55
|
-
* [ ] Binary vs. text mode for stdin/stdout/stderr
|
56
|
-
* [ ] Environment isolation like Process.spawn
|
57
|
-
* [ ] Pass options to Process.spawn (chdir, umask, pgroup, etc.)
|
58
|
-
* [ ] Don't allow optionis to Process.spawn that would break the functionality
|
59
|
-
(:in, :out, :err, integer, #fileno, :close_others)
|
60
|
-
|
61
101
|
## License
|
62
102
|
|
63
103
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,237 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
4
|
+
require 'io/wait'
|
5
|
+
|
6
|
+
module ProcessExecuter
|
7
|
+
# Stream data sent through a pipe to one or more writers
|
8
|
+
#
|
9
|
+
# When a new MonitoredPipe is created, a pipe is created (via IO.pipe) and
|
10
|
+
# a thread is created to read data written to the pipe.
|
11
|
+
#
|
12
|
+
# Data that is read from the pipe is written one or more writers passed to
|
13
|
+
# `#initialize`.
|
14
|
+
#
|
15
|
+
# `#close` must be called to ensure that (1) the pipe is closed, (2) all data is
|
16
|
+
# read from the pipe and written to the writers, and (3) the monitoring thread is
|
17
|
+
# killed.
|
18
|
+
#
|
19
|
+
# @example Collect pipe data into a string
|
20
|
+
# pipe_data = StringIO.new
|
21
|
+
# begin
|
22
|
+
# pipe = MonitoredPipe.new(pipe_data)
|
23
|
+
# pipe.write("Hello World")
|
24
|
+
# ensure
|
25
|
+
# pipe.close
|
26
|
+
# end
|
27
|
+
# pipe_data.string #=> "Hello World"
|
28
|
+
#
|
29
|
+
# @example Collect pipe data into a string AND a file
|
30
|
+
# pipe_data_string = StringIO.new
|
31
|
+
# pipe_data_file = File.open("pipe_data.txt", "w")
|
32
|
+
# begin
|
33
|
+
# pipe = MonitoredPipe.new(pipe_data_string, pipe_data_file)
|
34
|
+
# pipe.write("Hello World")
|
35
|
+
# ensure
|
36
|
+
# pipe.close
|
37
|
+
# end
|
38
|
+
# pipe_data_string.string #=> "Hello World"
|
39
|
+
# File.read("pipe_data.txt") #=> "Hello World"
|
40
|
+
#
|
41
|
+
# @api public
|
42
|
+
#
|
43
|
+
class MonitoredPipe
|
44
|
+
# Create a new monitored pipe
|
45
|
+
#
|
46
|
+
# Creates a IO.pipe and starts a monitoring thread to read data written to the pipe.
|
47
|
+
#
|
48
|
+
# @example
|
49
|
+
# data_collector = StringIO.new
|
50
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
51
|
+
#
|
52
|
+
# @param writers [Array<#write>] as data is read from the pipe, it is written to these writers
|
53
|
+
# @param chunk_size [Integer] the size of the chunks to read from the pipe
|
54
|
+
#
|
55
|
+
def initialize(*writers, chunk_size: 1000)
|
56
|
+
@pipe_reader, @pipe_writer = IO.pipe
|
57
|
+
@chunk_size = chunk_size
|
58
|
+
@writers = writers
|
59
|
+
@thread = Thread.new { monitor_pipe }
|
60
|
+
end
|
61
|
+
|
62
|
+
# Kill the monitoring thread, read remaining data, and close the pipe
|
63
|
+
#
|
64
|
+
# @example
|
65
|
+
# require 'stringio'
|
66
|
+
# data_collector = StringIO.new
|
67
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
68
|
+
# pipe.write('Hello World')
|
69
|
+
# pipe.close
|
70
|
+
# data_collector.string #=> "Hello World"
|
71
|
+
#
|
72
|
+
# @return [void]
|
73
|
+
#
|
74
|
+
def close
|
75
|
+
thread.kill
|
76
|
+
thread.join
|
77
|
+
pipe_writer.close
|
78
|
+
read_pipe_output if pipe_reader.wait_readable(0)
|
79
|
+
pipe_reader.close
|
80
|
+
end
|
81
|
+
|
82
|
+
# Return the write end of the pipe so that data can be written to it
|
83
|
+
#
|
84
|
+
# Data written to this end of the pipe will be read by the monitor thread and
|
85
|
+
# written to the writers passed to `#initialize`.
|
86
|
+
#
|
87
|
+
# This is so we can provide a MonitoredPipe to Process.spawn as a FD
|
88
|
+
#
|
89
|
+
# @example
|
90
|
+
# require 'stringio'
|
91
|
+
# data_collector = StringIO.new
|
92
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
93
|
+
# pipe.to_io.write('Hello World')
|
94
|
+
# pipe.close
|
95
|
+
# data_collector.string #=> "Hello World"
|
96
|
+
#
|
97
|
+
# @return [IO] the write end of the pipe
|
98
|
+
#
|
99
|
+
# @api private
|
100
|
+
#
|
101
|
+
def to_io
|
102
|
+
pipe_writer
|
103
|
+
end
|
104
|
+
|
105
|
+
# @!attribute [r] fileno
|
106
|
+
#
|
107
|
+
# The file descriptor for the write end of the pipe
|
108
|
+
#
|
109
|
+
# @example
|
110
|
+
# require 'stringio'
|
111
|
+
# data_collector = StringIO.new
|
112
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
113
|
+
# pipe.fileno == pipe.to_io.fileno #=> true
|
114
|
+
#
|
115
|
+
# @return [Integer] the file descriptor for the write end of the pipe
|
116
|
+
#
|
117
|
+
# @api private
|
118
|
+
#
|
119
|
+
def fileno
|
120
|
+
pipe_writer.fileno
|
121
|
+
end
|
122
|
+
|
123
|
+
# Writes data to the pipe so that it can be read by the monitor thread
|
124
|
+
#
|
125
|
+
# Primarily used for testing.
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# require 'stringio'
|
129
|
+
# data_collector = StringIO.new
|
130
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
131
|
+
# pipe.write('Hello World')
|
132
|
+
# pipe.close
|
133
|
+
# data_collector.string #=> "Hello World"
|
134
|
+
#
|
135
|
+
# @param data [String] the data to write to the pipe
|
136
|
+
#
|
137
|
+
# @return [Integer] the number of bytes written to the pipe
|
138
|
+
#
|
139
|
+
# @api private
|
140
|
+
#
|
141
|
+
def write(data)
|
142
|
+
pipe_writer.write(data)
|
143
|
+
end
|
144
|
+
|
145
|
+
# @!attribute [r]
|
146
|
+
#
|
147
|
+
# The size of the chunks to read from the pipe
|
148
|
+
#
|
149
|
+
# @example
|
150
|
+
# require 'stringio'
|
151
|
+
# data_collector = StringIO.new
|
152
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
153
|
+
# pipe.chunk_size #=> 1000
|
154
|
+
#
|
155
|
+
# @return [Integer] the size of the chunks to read from the pipe
|
156
|
+
#
|
157
|
+
attr_reader :chunk_size
|
158
|
+
|
159
|
+
# @!attribute [r]
|
160
|
+
#
|
161
|
+
# An array of writers to write data that is read from the pipe
|
162
|
+
#
|
163
|
+
# @example with one writer
|
164
|
+
# require 'stringio'
|
165
|
+
# data_collector = StringIO.new
|
166
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
167
|
+
# pipe.writers #=> [data_collector]
|
168
|
+
#
|
169
|
+
# @example with an array of writers
|
170
|
+
# require 'stringio'
|
171
|
+
# data_collector1 = StringIO.new
|
172
|
+
# data_collector2 = StringIO.new
|
173
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector1, data_collector2)
|
174
|
+
# pipe.writers #=> [data_collector1, data_collector2]]
|
175
|
+
#
|
176
|
+
# @return [Array<#write>]
|
177
|
+
#
|
178
|
+
attr_reader :writers
|
179
|
+
|
180
|
+
# @!attribute [r]
|
181
|
+
#
|
182
|
+
# The thread that monitors the pipe
|
183
|
+
#
|
184
|
+
# @example
|
185
|
+
# require 'stringio'
|
186
|
+
# data_collector = StringIO.new
|
187
|
+
# pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
|
188
|
+
# pipe.thread #=> #<Thread:0x00007f8b1a0b0e00>
|
189
|
+
#
|
190
|
+
# @return [Thread]
|
191
|
+
attr_reader :thread
|
192
|
+
|
193
|
+
# @!attribute [r]
|
194
|
+
#
|
195
|
+
# The read end of the pipe
|
196
|
+
#
|
197
|
+
# @example
|
198
|
+
# pipe = ProcessExecuter::MonitoredPipe.new($stdout)
|
199
|
+
# pipe.pipe_reader #=> #<IO:fd 11>
|
200
|
+
#
|
201
|
+
# @return [IO]
|
202
|
+
attr_reader :pipe_reader
|
203
|
+
|
204
|
+
# @!attribute [r]
|
205
|
+
#
|
206
|
+
# The write end of the pipe
|
207
|
+
#
|
208
|
+
# @example
|
209
|
+
# pipe = ProcessExecuter::MonitoredPipe.new($stdout)
|
210
|
+
# pipe.pipe_writer #=> #<IO:fd 12>
|
211
|
+
#
|
212
|
+
# @return [IO] the write end of the pipe
|
213
|
+
attr_reader :pipe_writer
|
214
|
+
|
215
|
+
private
|
216
|
+
|
217
|
+
# Reads data from the pipe forever until the monitoring thread is killed
|
218
|
+
# @return [void]
|
219
|
+
# @api private
|
220
|
+
def monitor_pipe
|
221
|
+
loop do
|
222
|
+
read_pipe_output if pipe_reader.wait_readable
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Read a chunk of data from the pipe and write it to the writers
|
227
|
+
# @return [void]
|
228
|
+
# @api private
|
229
|
+
def read_pipe_output
|
230
|
+
new_data = pipe_reader.read_nonblock(chunk_size)
|
231
|
+
# puts "Received new data: #{new_data.inspect} from #{pipe_reader.inspect}"
|
232
|
+
writers.each { |w| w.write(new_data) }
|
233
|
+
rescue EOFError, IO::EAGAINWaitReadable
|
234
|
+
# No output to read at this time
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'ostruct'
|
5
|
+
|
6
|
+
module ProcessExecuter
|
7
|
+
# Validate ProcessExecuter::Executer#spawn options and return Process.spawn options
|
8
|
+
#
|
9
|
+
# Valid options are those accepted by Process.spawn plus the following additions:
|
10
|
+
#
|
11
|
+
# * `:timeout`:
|
12
|
+
#
|
13
|
+
# @api public
|
14
|
+
#
|
15
|
+
class Options
|
16
|
+
# These options should be passed to `Process.spawn`
|
17
|
+
#
|
18
|
+
# Additionally, any options whose key is an Integer or an IO object will
|
19
|
+
# be passed to `Process.spawn`.
|
20
|
+
#
|
21
|
+
SPAWN_OPTIONS = %i[
|
22
|
+
in out err unsetenv_others pgroup new_pgroup rlimit_resourcename umask
|
23
|
+
close_others chdir
|
24
|
+
].freeze
|
25
|
+
|
26
|
+
# These options are allowed but should NOT be passed to `Process.spawn`
|
27
|
+
#
|
28
|
+
NON_SPAWN_OPTIONS = %i[
|
29
|
+
timeout
|
30
|
+
].freeze
|
31
|
+
|
32
|
+
# Any `SPAWN_OPTIONS`` set to this value will not be passed to `Process.spawn`
|
33
|
+
#
|
34
|
+
NOT_SET = :not_set
|
35
|
+
|
36
|
+
# The default values for all options
|
37
|
+
# @return [Hash]
|
38
|
+
DEFAULTS = {
|
39
|
+
in: NOT_SET,
|
40
|
+
out: NOT_SET,
|
41
|
+
err: NOT_SET,
|
42
|
+
unsetenv_others: NOT_SET,
|
43
|
+
pgroup: NOT_SET,
|
44
|
+
new_pgroup: NOT_SET,
|
45
|
+
rlimit_resourcename: NOT_SET,
|
46
|
+
umask: NOT_SET,
|
47
|
+
close_others: NOT_SET,
|
48
|
+
chdir: NOT_SET,
|
49
|
+
timeout: nil
|
50
|
+
}.freeze
|
51
|
+
|
52
|
+
# All options allowed by this class
|
53
|
+
#
|
54
|
+
ALL_OPTIONS = (SPAWN_OPTIONS + NON_SPAWN_OPTIONS).freeze
|
55
|
+
|
56
|
+
# Create accessor functions for all options. Assumes that the options are stored
|
57
|
+
# in a hash named `@options`
|
58
|
+
#
|
59
|
+
ALL_OPTIONS.each do |option|
|
60
|
+
define_method(option) do
|
61
|
+
@options[option]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Create a new Options object
|
66
|
+
#
|
67
|
+
# @example
|
68
|
+
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
|
69
|
+
#
|
70
|
+
# @param options [Hash] Process.spawn options plus additional options listed below.
|
71
|
+
#
|
72
|
+
# See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
|
73
|
+
# for a list of valid.
|
74
|
+
#
|
75
|
+
# @option options [Integer, Float, nil] :timeout
|
76
|
+
# Number of seconds to wait for the process to terminate. Any number
|
77
|
+
# may be used, including Floats to specify fractional seconds. A value of 0 or nil
|
78
|
+
# will allow the process to run indefinitely.
|
79
|
+
#
|
80
|
+
def initialize(**options)
|
81
|
+
assert_no_unknown_options(options)
|
82
|
+
@options = DEFAULTS.merge(options)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns the options to be passed to Process.spawn
|
86
|
+
#
|
87
|
+
# @example
|
88
|
+
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
|
89
|
+
# options.spawn_options # => { out: $stdout, err: $stderr }
|
90
|
+
#
|
91
|
+
# @return [Hash]
|
92
|
+
#
|
93
|
+
def spawn_options
|
94
|
+
{}.tap do |spawn_options|
|
95
|
+
options.each do |option, value|
|
96
|
+
spawn_options[option] = value if include_spawn_option?(option, value)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
# @!attribute [r]
|
104
|
+
#
|
105
|
+
# Options with values
|
106
|
+
#
|
107
|
+
# All options have values. If an option is not given in the initializer, it
|
108
|
+
# will have the value `NOT_SET`.
|
109
|
+
#
|
110
|
+
# @return [Hash<Symbol, Object>]
|
111
|
+
#
|
112
|
+
# @api private
|
113
|
+
#
|
114
|
+
attr_reader :options
|
115
|
+
|
116
|
+
# Determine if the options hash contains any unknown options
|
117
|
+
# @param options [Hash] the hash of options
|
118
|
+
# @return [void]
|
119
|
+
# @raise [ArgumentError] if the options hash contains any unknown options
|
120
|
+
# @api private
|
121
|
+
def assert_no_unknown_options(options)
|
122
|
+
unknown_options = options.keys.reject { |key| valid_option?(key) }
|
123
|
+
raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
|
124
|
+
end
|
125
|
+
|
126
|
+
# Determine if the given option is a valid option
|
127
|
+
# @param option [Symbol] the option to be tested
|
128
|
+
# @return [Boolean] true if the given option is a valid option
|
129
|
+
# @api private
|
130
|
+
def valid_option?(option)
|
131
|
+
ALL_OPTIONS.include?(option) || option.is_a?(Integer) || option.respond_to?(:fileno)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Determine if the given option should be passed to `Process.spawn`
|
135
|
+
# @param option [Symbol, Integer, IO] the option to be tested
|
136
|
+
# @param value [Object] the value of the option
|
137
|
+
# @return [Boolean] true if the given option should be passed to `Process.spawn`
|
138
|
+
# @api private
|
139
|
+
def include_spawn_option?(option, value)
|
140
|
+
(option.is_a?(Integer) || option.is_a?(IO) || SPAWN_OPTIONS.include?(option)) && value != NOT_SET
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ProcessExecuter
|
4
|
+
# Spawns a process and knows how to check if the process is terminated
|
5
|
+
#
|
6
|
+
# This class is not currently used in this Gem.
|
7
|
+
#
|
8
|
+
# @api public
|
9
|
+
#
|
10
|
+
class Process
|
11
|
+
# Spawns a new process using Process.spawn
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# command = ['echo', 'hello world']
|
15
|
+
# options = { chdir: '/tmp' }
|
16
|
+
# process = ProcessExecuter::Process.new(*command, **options)
|
17
|
+
# process.pid # => 12345
|
18
|
+
# process.terminated? # => true
|
19
|
+
# process.status # => #<Process::Status: pid 12345 exit 0>
|
20
|
+
#
|
21
|
+
# @see https://ruby-doc.org/core/Process.html#method-c-spawn Process.spawn documentation
|
22
|
+
#
|
23
|
+
# @param command [Array] the command to execute
|
24
|
+
# @param spawn_options [Hash] the options to pass to Process.spawn
|
25
|
+
#
|
26
|
+
def initialize(*command, **spawn_options)
|
27
|
+
@pid = ::Process.spawn(*command, **spawn_options)
|
28
|
+
end
|
29
|
+
|
30
|
+
# @!attribute [r]
|
31
|
+
#
|
32
|
+
# The id of the process
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# ProcessExecuter::Process.new('echo', 'hello world').pid # => 12345
|
36
|
+
#
|
37
|
+
# @return [Integer] The id of the process
|
38
|
+
#
|
39
|
+
attr_reader :pid
|
40
|
+
|
41
|
+
# @!attribute [r]
|
42
|
+
#
|
43
|
+
# The exit status of the process or `nil` if the process has not terminated
|
44
|
+
#
|
45
|
+
# @example
|
46
|
+
# ProcessExecuter::Process.new('echo', 'hello world').status # => #<Process::Status: pid 12345 exit 0>
|
47
|
+
#
|
48
|
+
# @return [::Process::Status, nil]
|
49
|
+
#
|
50
|
+
# The status is set only when `terminated?` is called and returns `true`.
|
51
|
+
#
|
52
|
+
attr_reader :status
|
53
|
+
|
54
|
+
# Return true if the process has terminated
|
55
|
+
#
|
56
|
+
# If the proces has terminated, `#status` is set to the exit status of the process.
|
57
|
+
#
|
58
|
+
# @example
|
59
|
+
# process = ProcessExecuter::Process.new('echo', 'hello world')
|
60
|
+
# sleep 1
|
61
|
+
# process.terminated? # => true
|
62
|
+
#
|
63
|
+
# @return [Boolean] true if the process has terminated
|
64
|
+
#
|
65
|
+
def terminated?
|
66
|
+
return true if @status
|
67
|
+
|
68
|
+
_pid, @status = ::Process.wait2(pid, ::Process::WNOHANG)
|
69
|
+
!@status.nil?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,345 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ProcessExecuter
|
4
|
+
# A replacement for Process::Status that can be used to mock the exit status of a process
|
5
|
+
#
|
6
|
+
# This class is not currently used in this Gem.
|
7
|
+
#
|
8
|
+
# Process::Status encapsulates the information on the status of a running or
|
9
|
+
# terminated system process. The built-in variable $? is either nil or a
|
10
|
+
# Process::Status object.
|
11
|
+
#
|
12
|
+
# ```ruby
|
13
|
+
# fork { exit 99 } #=> 26557
|
14
|
+
# Process.wait #=> 26557
|
15
|
+
# $?.class #=> Process::Status
|
16
|
+
# $?.to_i #=> 25344
|
17
|
+
# $? >> 8 #=> 99
|
18
|
+
# $?.stopped? #=> false
|
19
|
+
# $?.exited? #=> true
|
20
|
+
# $?.exitstatus #=> 99
|
21
|
+
# ```
|
22
|
+
#
|
23
|
+
# Posix systems record information on processes using a 16-bit integer. The
|
24
|
+
# lower bits record the process status (stopped, exited, signaled) and the
|
25
|
+
# upper bits possibly contain additional information (for example the program's
|
26
|
+
# return code in the case of exited processes). Pre Ruby 1.8, these bits were
|
27
|
+
# exposed directly to the Ruby program. Ruby now encapsulates these in a
|
28
|
+
# Process::Status object. To maximize compatibility, however, these objects
|
29
|
+
# retain a bit-oriented interface. In the descriptions that follow, when we
|
30
|
+
# talk about the integer value of stat, we're referring to this 16 bit value.
|
31
|
+
#
|
32
|
+
# @api public
|
33
|
+
#
|
34
|
+
class Status
|
35
|
+
# Create a new Status object
|
36
|
+
#
|
37
|
+
# @example
|
38
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
39
|
+
# status.exited? # => true
|
40
|
+
# status.success? # => true
|
41
|
+
# status.exitstatus # => 0
|
42
|
+
#
|
43
|
+
def initialize(pid, stat)
|
44
|
+
@pid = pid
|
45
|
+
@stat = stat
|
46
|
+
end
|
47
|
+
|
48
|
+
# @!attribute
|
49
|
+
#
|
50
|
+
# The pid of the process
|
51
|
+
#
|
52
|
+
# @example
|
53
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
54
|
+
# status.pid # => 999
|
55
|
+
#
|
56
|
+
# @return [Integer]
|
57
|
+
#
|
58
|
+
# @api public
|
59
|
+
#
|
60
|
+
attr_reader :pid
|
61
|
+
|
62
|
+
# @!attribute
|
63
|
+
#
|
64
|
+
# The status code of the process
|
65
|
+
#
|
66
|
+
# @example
|
67
|
+
# status = ProcessExecuter::Status.new(999, 123)
|
68
|
+
# status.stat # => 123
|
69
|
+
#
|
70
|
+
# @return [Integer]
|
71
|
+
#
|
72
|
+
# @api public
|
73
|
+
#
|
74
|
+
attr_reader :stat
|
75
|
+
|
76
|
+
# Logical AND of the bits in stat with `other`
|
77
|
+
#
|
78
|
+
# @example Process ended due to an uncaught signal 11 with a core dump
|
79
|
+
# status = ProcessExecuter::Status.new(999, 139)
|
80
|
+
# status & 127 # => 11 => the uncaught signal
|
81
|
+
# !(status & 128).zero? # => true => indicating a core dump
|
82
|
+
#
|
83
|
+
# @param other [Integer] the value to AND with stat
|
84
|
+
#
|
85
|
+
# @return [Integer] the result of the AND operation
|
86
|
+
#
|
87
|
+
def &(other)
|
88
|
+
stat & other
|
89
|
+
end
|
90
|
+
|
91
|
+
# Compare stat to `other`
|
92
|
+
#
|
93
|
+
# @example Process exited normally with exitstatus 99
|
94
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
95
|
+
# status == 25_344 # => true
|
96
|
+
#
|
97
|
+
# @param other [Integer] the value to compare stat to
|
98
|
+
#
|
99
|
+
# @return [Boolean] true if stat == other, false otherwise
|
100
|
+
#
|
101
|
+
def ==(other)
|
102
|
+
stat == other
|
103
|
+
end
|
104
|
+
|
105
|
+
# rubocop:disable Naming/BinaryOperatorParameterName
|
106
|
+
|
107
|
+
# Shift the bits in stat right `num` places
|
108
|
+
#
|
109
|
+
# @example Process exited normally with exitstatus 99
|
110
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
111
|
+
# status >> 8 # => 99
|
112
|
+
#
|
113
|
+
# @param num [Integer] the number of places to shift stat
|
114
|
+
#
|
115
|
+
# @return [Integer] the result of the shift operation
|
116
|
+
#
|
117
|
+
def >>(num)
|
118
|
+
stat >> num
|
119
|
+
end
|
120
|
+
|
121
|
+
# rubocop:enable Naming/BinaryOperatorParameterName
|
122
|
+
|
123
|
+
# Returns true if the process generated a coredump upon termination
|
124
|
+
#
|
125
|
+
# Not available on all platforms.
|
126
|
+
#
|
127
|
+
# @example process exited normally with exitstatus 99
|
128
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
129
|
+
# status.coredump? # => false
|
130
|
+
#
|
131
|
+
# @example process ended due to an uncaught signal 11 with a core dump
|
132
|
+
# status = ProcessExecuter::Status.new(999, 139)
|
133
|
+
# status.coredump? # => true
|
134
|
+
#
|
135
|
+
# @return [Boolean] true if stat generated a coredump when it terminated
|
136
|
+
#
|
137
|
+
def coredump?
|
138
|
+
!(stat & 128).zero?
|
139
|
+
end
|
140
|
+
|
141
|
+
# Returns true if the process exited normally
|
142
|
+
#
|
143
|
+
# This happens when the process uses an exit() call or runs to the end of the program.
|
144
|
+
#
|
145
|
+
# @example process exited normally with exitstatus 0
|
146
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
147
|
+
# status.exited? # => true
|
148
|
+
#
|
149
|
+
# @example process exited normally with exitstatus 99
|
150
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
151
|
+
# status.exited? # => true
|
152
|
+
#
|
153
|
+
# @example process ended due to an uncaught signal 11 with a core dump
|
154
|
+
# status = ProcessExecuter::Status.new(999, 139)
|
155
|
+
# status.exited? # => false
|
156
|
+
#
|
157
|
+
# @return [Boolean] true if the process exited normally
|
158
|
+
#
|
159
|
+
def exited?
|
160
|
+
(stat & 127).zero?
|
161
|
+
end
|
162
|
+
|
163
|
+
# Returns the exit status of the process
|
164
|
+
#
|
165
|
+
# Returns nil if the process did not exit normally (when `#exited?` is false).
|
166
|
+
#
|
167
|
+
# @example process exited normally with exitstatus 99
|
168
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
169
|
+
# status.exitstatus # => 99
|
170
|
+
#
|
171
|
+
# @return [Integer, nil] the exit status of the process
|
172
|
+
#
|
173
|
+
def exitstatus
|
174
|
+
stat >> 8 if exited?
|
175
|
+
end
|
176
|
+
|
177
|
+
# Returns true if the process was successful
|
178
|
+
#
|
179
|
+
# This means that `exited?` is true and `#exitstatus` is 0.
|
180
|
+
#
|
181
|
+
# Returns nil if the process did not exit normally (when `#exited?` is false).
|
182
|
+
#
|
183
|
+
# @example process exited normally with exitstatus 0
|
184
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
185
|
+
# status.success? # => true
|
186
|
+
#
|
187
|
+
# @example process exited normally with exitstatus 99
|
188
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
189
|
+
# status.success? # => false
|
190
|
+
#
|
191
|
+
# @example process ended due to an uncaught signal 11 with a core dump
|
192
|
+
# status = ProcessExecuter::Status.new(999, 139)
|
193
|
+
# status.success? # => nil
|
194
|
+
#
|
195
|
+
# @return [Boolean, nil] true if successful, false if unsuccessful, nil if the process did not exit normally
|
196
|
+
#
|
197
|
+
def success?
|
198
|
+
exitstatus.zero? if exited?
|
199
|
+
end
|
200
|
+
|
201
|
+
# Returns true if the process was stopped
|
202
|
+
#
|
203
|
+
# @example with a stopped process with signal 17
|
204
|
+
# status = ProcessExecuter::Status.new(999, 4_479)
|
205
|
+
# status.stopped? # => true
|
206
|
+
#
|
207
|
+
# @example process exited normally with exitstatus 99
|
208
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
209
|
+
# status.stopped? # => false
|
210
|
+
#
|
211
|
+
# @example process ended due to an uncaught signal 11 with a core dump
|
212
|
+
# status = ProcessExecuter::Status.new(999, 139)
|
213
|
+
# status.stopped? # => false
|
214
|
+
#
|
215
|
+
# @return [Boolean] true if the process was stopped, false otherwise
|
216
|
+
#
|
217
|
+
def stopped?
|
218
|
+
(stat & 127) == 127
|
219
|
+
end
|
220
|
+
|
221
|
+
# The signal number that casused the process to stop
|
222
|
+
#
|
223
|
+
# Returns nil if the process is not stopped.
|
224
|
+
#
|
225
|
+
# @example with a stopped process with signal 17
|
226
|
+
# status = ProcessExecuter::Status.new(999, 4_479)
|
227
|
+
# status.stopsig # => 17
|
228
|
+
#
|
229
|
+
# @example process exited normally with exitstatus 99
|
230
|
+
# status = ProcessExecuter::Status.new(999, 25_344)
|
231
|
+
# status.stopsig # => nil
|
232
|
+
#
|
233
|
+
# @return [Integer, nil] the signal number that caused the process to stop or nil
|
234
|
+
#
|
235
|
+
def stopsig
|
236
|
+
stat >> 8 if stopped?
|
237
|
+
end
|
238
|
+
|
239
|
+
# Returns true if stat terminated because of an uncaught signal
|
240
|
+
#
|
241
|
+
# @example process ended due to an uncaught signal 9
|
242
|
+
# status = ProcessExecuter::Status.new(999, 9)
|
243
|
+
# status.signaled? # => true
|
244
|
+
#
|
245
|
+
# @example process exited normally with exitstatus 0
|
246
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
247
|
+
# status.signaled? # => false
|
248
|
+
#
|
249
|
+
# @return [Boolean] true if stat terminated because of an uncaught signal, false otherwise
|
250
|
+
#
|
251
|
+
def signaled?
|
252
|
+
![0, 127].include?(stat & 127)
|
253
|
+
end
|
254
|
+
|
255
|
+
# Returns the number of the signal that caused the process to terminate
|
256
|
+
#
|
257
|
+
# Returns nil if the process exited normally or is stopped.
|
258
|
+
#
|
259
|
+
# @example process ended due to an uncaught signal 9
|
260
|
+
# status = ProcessExecuter::Status.new(999, 9)
|
261
|
+
# status.termsig # => 9
|
262
|
+
#
|
263
|
+
# @example process exited normally with exitstatus 0
|
264
|
+
# status = ProcessExecuter::Status.new(999, 0)
|
265
|
+
# status.termsig # => nil
|
266
|
+
#
|
267
|
+
# @return [Integer, nil] the signal number that caused the process to terminate or nil
|
268
|
+
#
|
269
|
+
def termsig
|
270
|
+
stat & 127 if signaled?
|
271
|
+
end
|
272
|
+
|
273
|
+
# Returns the bits in stat as an Integer
|
274
|
+
#
|
275
|
+
# @example with a stopped process with signal 17
|
276
|
+
# status = ProcessExecuter::Status.new(999, 4_479)
|
277
|
+
# status.to_i # => 4_479
|
278
|
+
#
|
279
|
+
# @return [Integer] the bits in stat
|
280
|
+
#
|
281
|
+
def to_i
|
282
|
+
stat
|
283
|
+
end
|
284
|
+
|
285
|
+
# Show the status type, pid, and exit status as a string
|
286
|
+
#
|
287
|
+
# @example with a stopped process with signal 17
|
288
|
+
# status = ProcessExecuter::Status.new(999, 4_479)
|
289
|
+
# status.to_s # => "pid 999 stopped SIGSTOP (signal 17)"
|
290
|
+
#
|
291
|
+
# @return [String] the status type, pid, and exit status as a string
|
292
|
+
#
|
293
|
+
def to_s
|
294
|
+
type_to_s + (coredump? ? ' (core dumped)' : '')
|
295
|
+
end
|
296
|
+
|
297
|
+
# Show the status type, pid, and exit status as a string
|
298
|
+
#
|
299
|
+
# @example with a stopped process with signal 17
|
300
|
+
# status = ProcessExecuter::Status.new(999, 4_479)
|
301
|
+
# status.inspect # => "#<ProcessExecuter::Status pid 999 stopped SIGSTOP (signal 17)>"
|
302
|
+
#
|
303
|
+
# @return [String] the status type, pid, and exit status as a string
|
304
|
+
#
|
305
|
+
def inspect
|
306
|
+
"#<#{self.class} #{self}>"
|
307
|
+
end
|
308
|
+
|
309
|
+
private
|
310
|
+
|
311
|
+
# The string representation of a status based on how it was terminated
|
312
|
+
# @return [String] the string representation
|
313
|
+
# @api private
|
314
|
+
def type_to_s
|
315
|
+
if signaled?
|
316
|
+
signaled_to_s
|
317
|
+
elsif exited?
|
318
|
+
exited_to_s
|
319
|
+
elsif stopped?
|
320
|
+
stopped_to_s
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# The string representation of a signaled process
|
325
|
+
# @return [String] the string representation of a signaled process
|
326
|
+
# @api private
|
327
|
+
def signaled_to_s
|
328
|
+
"pid #{pid} SIG#{Signal.signame(termsig)} (signal #{termsig})"
|
329
|
+
end
|
330
|
+
|
331
|
+
# The string representation of an exited process
|
332
|
+
# @return [String] the string representation of an exited process
|
333
|
+
# @api private
|
334
|
+
def exited_to_s
|
335
|
+
"pid #{pid} exit #{exitstatus}"
|
336
|
+
end
|
337
|
+
|
338
|
+
# The string representation of a stopped process
|
339
|
+
# @return [String] the string representation of a stopped process
|
340
|
+
# @api private
|
341
|
+
def stopped_to_s
|
342
|
+
"pid #{pid} stopped SIG#{Signal.signame(stopsig)} (signal #{stopsig})"
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
data/lib/process_executer.rb
CHANGED
@@ -1,225 +1,68 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require 'process_executer/monitored_pipe'
|
4
|
+
require 'process_executer/options'
|
5
|
+
require 'process_executer/process'
|
6
|
+
require 'process_executer/status'
|
4
7
|
|
5
|
-
require '
|
6
|
-
# require 'nio'
|
8
|
+
require 'timeout'
|
7
9
|
|
8
|
-
# Execute a
|
9
|
-
# pass the output through to this process's stdout/stderr.
|
10
|
+
# Execute a command in a subprocess and optionally capture its output
|
10
11
|
#
|
11
12
|
# @api public
|
12
13
|
#
|
13
|
-
|
14
|
-
#
|
14
|
+
module ProcessExecuter
|
15
|
+
# Execute the specified command and return the exit status
|
15
16
|
#
|
16
|
-
#
|
17
|
-
# command = ProcessExecuter.new
|
18
|
-
# command.execute('echo hello')
|
19
|
-
# command.out # => "hello\n"
|
20
|
-
#
|
21
|
-
# @return [String, nil] nil if collect_out? is false
|
22
|
-
#
|
23
|
-
attr_reader :out
|
24
|
-
|
25
|
-
# stderr collected from the command
|
26
|
-
#
|
27
|
-
# @example
|
28
|
-
# command = ProcessExecuter.new
|
29
|
-
# command.execute('echo hello 1>&2')
|
30
|
-
# command.err # => "hello\n"
|
31
|
-
#
|
32
|
-
# @return [String, nil] nil if collect_err? is false
|
33
|
-
#
|
34
|
-
attr_reader :err
|
35
|
-
|
36
|
-
# The status of the command process
|
37
|
-
#
|
38
|
-
# Will be `nil` if the command has not completed execution.
|
39
|
-
#
|
40
|
-
# @example
|
41
|
-
# command = ProcessExecuter.new
|
42
|
-
# command.execute('echo hello')
|
43
|
-
# command.status # => #<Process::Status: pid 86235 exit 0>
|
44
|
-
#
|
45
|
-
# @return [Process::Status, nil]
|
46
|
-
#
|
47
|
-
attr_reader :status
|
48
|
-
|
49
|
-
# Show the command's stdout on this process's stdout
|
50
|
-
#
|
51
|
-
# @example
|
52
|
-
# command = ProcessExecuter.new(passthru_out: true)
|
53
|
-
# command.passthru_out? # => true
|
54
|
-
#
|
55
|
-
# @return [Boolean]
|
56
|
-
#
|
57
|
-
def passthru_out?; !!@passthru_out; end
|
58
|
-
|
59
|
-
# Show the command's stderr on this process's stderr
|
60
|
-
#
|
61
|
-
# @example
|
62
|
-
# command = ProcessExecuter.new(passthru_err: true)
|
63
|
-
# command.passthru_err? # => true
|
64
|
-
#
|
65
|
-
# @return [Boolean]
|
66
|
-
#
|
67
|
-
def passthru_err?; !!@passthru_err; end
|
68
|
-
|
69
|
-
# Collect the command's stdout the :out string (default is true)
|
17
|
+
# This method blocks until the command has terminated or the timeout has been reached.
|
70
18
|
#
|
71
19
|
# @example
|
72
|
-
#
|
73
|
-
#
|
74
|
-
#
|
75
|
-
#
|
76
|
-
#
|
77
|
-
def collect_out?; !!@collect_out; end
|
78
|
-
|
79
|
-
# Collect the command's stderr the :err string (default is true)
|
20
|
+
# status = ProcessExecuter.spawn('echo hello')
|
21
|
+
# status.exited? # => true
|
22
|
+
# status.success? # => true
|
23
|
+
# stdout.string # => "hello\n"
|
80
24
|
#
|
81
|
-
# @example
|
82
|
-
#
|
83
|
-
#
|
25
|
+
# @example with a timeout
|
26
|
+
# status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
|
27
|
+
# status.exited? # => false
|
28
|
+
# status.success? # => nil
|
29
|
+
# status.signaled? # => true
|
30
|
+
# status.termsig # => 9
|
84
31
|
#
|
85
|
-
# @
|
32
|
+
# @see https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-spawn Kernel.spawn
|
33
|
+
# documentation for valid command and options
|
86
34
|
#
|
87
|
-
|
88
|
-
|
89
|
-
# Create a new ProcessExecuter
|
35
|
+
# @see ProcessExecuter::Options#initialize See ProcessExecuter::Options#initialize
|
36
|
+
# for additional options that may be specified
|
90
37
|
#
|
91
|
-
# @
|
92
|
-
#
|
38
|
+
# @param command [Array<String>] the command to execute
|
39
|
+
# @param options_hash [Hash] the options to use for this execution context
|
93
40
|
#
|
94
|
-
# @
|
95
|
-
# @param passthru_err [Boolean] show the command's stderr on this process's stderr
|
96
|
-
# @param collect_out [Boolean] collect the command's stdout the :out string
|
97
|
-
# @param collect_err [Boolean] collect the command's stderr the :err string
|
41
|
+
# @return [ProcessExecuter::ExecutionContext] the execution context that can run commands
|
98
42
|
#
|
99
|
-
def
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
@collect_err = collect_err
|
43
|
+
def self.spawn(*command, **options_hash)
|
44
|
+
options = ProcessExecuter::Options.new(**options_hash)
|
45
|
+
pid = ::Process.spawn(*command, **options.spawn_options)
|
46
|
+
wait_for_process(pid, options)
|
104
47
|
end
|
105
48
|
|
106
|
-
#
|
107
|
-
# rubocop:disable Metrics/MethodLength
|
108
|
-
|
109
|
-
# Execute the given command in a subprocess
|
49
|
+
# Wait for process to terminate
|
110
50
|
#
|
111
|
-
#
|
51
|
+
# If a timeout is speecified in options, kill the process after options.timeout seconds.
|
112
52
|
#
|
113
|
-
#
|
53
|
+
# @param pid [Integer] the process id
|
54
|
+
# @param options [ProcessExecuter::Options] the options used
|
114
55
|
#
|
115
|
-
# @
|
116
|
-
# result = ProcessExecuter.new.execute('echo hello')
|
56
|
+
# @return [ProcessExecuter::Status] the status of the process
|
117
57
|
#
|
118
|
-
# @example Execute a command as with each argument as a separate string
|
119
|
-
# result = ProcessExecuter.new.execute('echo', 'hello')
|
120
|
-
#
|
121
|
-
# @example Execute a command in a specific directory
|
122
|
-
# result = ProcessExecuter.new.execute('pwd', chdir: '/tmp')
|
123
|
-
# result.out # => "/tmp\n"
|
124
|
-
#
|
125
|
-
# @example Execute a command with specific environment variables
|
126
|
-
# result = ProcessExecuter.new.execute({ 'FOO' => 'bar' }, 'echo $FOO' )
|
127
|
-
# result.out # => "bar\n"
|
128
|
-
#
|
129
|
-
# @param command [String, Array<String>] the command to pass to Process.spawn
|
130
|
-
# @param options [Hash] options to pass to Process.spawn
|
131
|
-
#
|
132
|
-
# @return [ProcessExecuter::Result] the result of the command execution
|
133
|
-
#
|
134
|
-
def execute(*command, **options)
|
135
|
-
@status = nil
|
136
|
-
@out = (collect_out? ? '' : nil)
|
137
|
-
@err = (collect_err? ? '' : nil)
|
138
|
-
|
139
|
-
out_reader, out_writer = IO.pipe
|
140
|
-
err_reader, err_writer = IO.pipe
|
141
|
-
|
142
|
-
options[:out] = out_writer
|
143
|
-
options[:err] = err_writer
|
144
|
-
|
145
|
-
pid = Process.spawn(*command, options)
|
146
|
-
|
147
|
-
loop do
|
148
|
-
read_command_output(out_reader, err_reader)
|
149
|
-
|
150
|
-
_pid, @status = Process.wait2(pid, Process::WNOHANG)
|
151
|
-
break if @status
|
152
|
-
|
153
|
-
# puts "finished_pid: #{finished_pid}"
|
154
|
-
# puts "status: #{status}"
|
155
|
-
|
156
|
-
# puts 'starting select'
|
157
|
-
# readers, writers, exceptions = IO.select([stdout_reader, stderr_reader], nil, nil, 0.1)
|
158
|
-
IO.select([out_reader, err_reader], nil, nil, 0.05)
|
159
|
-
|
160
|
-
# puts "readers: #{readers}"
|
161
|
-
# puts "writers: #{writers}"
|
162
|
-
# puts "exceptions: #{exceptions}"
|
163
|
-
|
164
|
-
# break unless readers || writers || exceptions
|
165
|
-
|
166
|
-
_pid, @status = Process.wait2(pid, Process::WNOHANG)
|
167
|
-
break if @status
|
168
|
-
|
169
|
-
# puts "finished_pid: #{finished_pid}"
|
170
|
-
# puts "status: #{status}"
|
171
|
-
end
|
172
|
-
|
173
|
-
out_writer.close
|
174
|
-
err_writer.close
|
175
|
-
|
176
|
-
# Read whatever is left over after the process terminates
|
177
|
-
read_command_output(out_reader, err_reader)
|
178
|
-
ProcessExecuter::Result.new(@status, @out, @err)
|
179
|
-
end
|
180
|
-
|
181
|
-
# rubocop:enable Metrics/AbcSize
|
182
|
-
# rubocop:enable Metrics/MethodLength
|
183
|
-
|
184
|
-
private
|
185
|
-
|
186
|
-
# Read output from the given readers
|
187
|
-
# @return [void]
|
188
58
|
# @api private
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
59
|
+
#
|
60
|
+
private_class_method def self.wait_for_process(pid, options)
|
61
|
+
Timeout.timeout(options.timeout) do
|
62
|
+
::Process.wait2(pid).last
|
193
63
|
end
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
# @return [void]
|
198
|
-
# @api private
|
199
|
-
def read_out(reader)
|
200
|
-
new_data = reader.read_nonblock(1024)
|
201
|
-
# puts "new_stdout: '#{new_data}'"
|
202
|
-
@out += new_data if new_data && collect_out?
|
203
|
-
puts new_data if new_data && passthru_out?
|
204
|
-
true
|
205
|
-
rescue EOFError, IO::EAGAINWaitReadable
|
206
|
-
# Nothing to read at this time
|
207
|
-
false
|
208
|
-
end
|
209
|
-
|
210
|
-
# Read stderr from the given reader
|
211
|
-
# @return [void]
|
212
|
-
# @api private
|
213
|
-
def read_err(reader)
|
214
|
-
new_data = reader.read_nonblock(1024)
|
215
|
-
# puts "new_stderr: '#{new_data}'"
|
216
|
-
@err += new_data if new_data && collect_err?
|
217
|
-
warn new_data if new_data && passthru_err?
|
218
|
-
true
|
219
|
-
rescue EOFError, IO::EAGAINWaitReadable
|
220
|
-
# Nothing to read at this time
|
221
|
-
false
|
64
|
+
rescue Timeout::Error
|
65
|
+
::Process.kill('KILL', pid)
|
66
|
+
::Process.wait2(pid).last
|
222
67
|
end
|
223
68
|
end
|
224
|
-
|
225
|
-
# rubocop:enable Style/SingleLineMethods
|
data/process_executer.gemspec
CHANGED
@@ -41,6 +41,7 @@ Gem::Specification.new do |spec|
|
|
41
41
|
spec.add_development_dependency 'rspec', '~> 3.10'
|
42
42
|
spec.add_development_dependency 'rubocop', '~> 1.36'
|
43
43
|
spec.add_development_dependency 'simplecov', '~> 0.21'
|
44
|
+
spec.add_development_dependency 'simplecov-lcov', '~> 0.8'
|
44
45
|
spec.add_development_dependency 'solargraph', '~> 0.47'
|
45
46
|
spec.add_development_dependency 'yard', '~> 0.9'
|
46
47
|
spec.add_development_dependency 'yardstick', '~> 0.9'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: process_executer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James Couball
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-12-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bump
|
@@ -122,6 +122,20 @@ dependencies:
|
|
122
122
|
- - "~>"
|
123
123
|
- !ruby/object:Gem::Version
|
124
124
|
version: '0.21'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: simplecov-lcov
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0.8'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0.8'
|
125
139
|
- !ruby/object:Gem::Dependency
|
126
140
|
name: solargraph
|
127
141
|
requirement: !ruby/object:Gem::Requirement
|
@@ -181,7 +195,10 @@ files:
|
|
181
195
|
- README.md
|
182
196
|
- Rakefile
|
183
197
|
- lib/process_executer.rb
|
184
|
-
- lib/process_executer/
|
198
|
+
- lib/process_executer/monitored_pipe.rb
|
199
|
+
- lib/process_executer/options.rb
|
200
|
+
- lib/process_executer/process.rb
|
201
|
+
- lib/process_executer/status.rb
|
185
202
|
- lib/process_executer/version.rb
|
186
203
|
- process_executer.gemspec
|
187
204
|
homepage: https://github.com/main_branch/process_executer
|
@@ -1,68 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class ProcessExecuter
|
4
|
-
# The result of executing a command
|
5
|
-
#
|
6
|
-
# @api public
|
7
|
-
#
|
8
|
-
class Result
|
9
|
-
# The status of the command process
|
10
|
-
#
|
11
|
-
# @example
|
12
|
-
# status # => #<Process::Status: pid 86235 exit 0>
|
13
|
-
# out = 'hello'
|
14
|
-
# err = 'ERROR'
|
15
|
-
# command = ProcessExecuter.new(status, out, err)
|
16
|
-
# command.status # => #<Process::Status: pid 86235 exit 0>
|
17
|
-
#
|
18
|
-
# @return [Process::Status]
|
19
|
-
#
|
20
|
-
attr_reader :status
|
21
|
-
|
22
|
-
# The command's stdout (if collected)
|
23
|
-
#
|
24
|
-
# @example
|
25
|
-
# status # => #<Process::Status: pid 86235 exit 0>
|
26
|
-
# out = 'hello'
|
27
|
-
# err = 'ERROR'
|
28
|
-
# command = ProcessExecuter.new(status, out, err)
|
29
|
-
# command.out # => "hello\n"
|
30
|
-
#
|
31
|
-
# @return [String, nil]
|
32
|
-
#
|
33
|
-
attr_reader :out
|
34
|
-
|
35
|
-
# The command's stderr (if collected)
|
36
|
-
#
|
37
|
-
# @example
|
38
|
-
# status # => #<Process::Status: pid 86235 exit 0>
|
39
|
-
# out = 'hello'
|
40
|
-
# err = 'ERROR'
|
41
|
-
# command = ProcessExecuter.new(status, out, err)
|
42
|
-
# command.out # => "ERROR\n"
|
43
|
-
#
|
44
|
-
# @return [String, nil]
|
45
|
-
#
|
46
|
-
attr_reader :err
|
47
|
-
|
48
|
-
# Create a new Result object
|
49
|
-
#
|
50
|
-
# @example
|
51
|
-
# status # => #<Process::Status: pid 86235 exit 0>
|
52
|
-
# out = 'hello'
|
53
|
-
# err = 'ERROR'
|
54
|
-
# command = ProcessExecuter.new(status, out, err)
|
55
|
-
#
|
56
|
-
# @param status [Process::Status] the status of the command process
|
57
|
-
# @param out [String, nil] the command's stdout (if collected)
|
58
|
-
# @param err [String, nil] the command's stderr (if collected)
|
59
|
-
#
|
60
|
-
# @return [ProcessExecuter::Result] the result object
|
61
|
-
#
|
62
|
-
def initialize(status, out, err)
|
63
|
-
@status = status
|
64
|
-
@out = out
|
65
|
-
@err = err
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|