process_executer 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/process_executer.svg)](https://badge.fury.io/rb/process_executer)
|
4
|
+
[![Build Status](https://github.com/main-branch/process_executer/workflows/Ruby/badge.svg?branch=main)](https://github.com/main-branch/process_executer/actions?query=workflow%3ARuby)
|
5
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/0b5c67e5c2a773009cd0/maintainability)](https://codeclimate.com/github/main-branch/process_executer/maintainability)
|
6
|
+
[![Test Coverage](https://api.codeclimate.com/v1/badges/0b5c67e5c2a773009cd0/test_coverage)](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
|