exec_service 0.0.0 → 0.1.1
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/.yardopts +11 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.md +21 -0
- data/README.md +101 -7
- data/lib/exec_service/controller.rb +359 -0
- data/lib/exec_service/executor.rb +962 -0
- data/lib/exec_service/opts.rb +102 -0
- data/lib/exec_service/result.rb +191 -0
- data/lib/exec_service/version.rb +9 -0
- data/lib/exec_service.rb +511 -6
- metadata +40 -11
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ExecService
|
|
4
|
+
##
|
|
5
|
+
# An object that manages the execution of a subcommand
|
|
6
|
+
#
|
|
7
|
+
# @private
|
|
8
|
+
#
|
|
9
|
+
class Executor
|
|
10
|
+
##
|
|
11
|
+
# Build an executor for a single subprocess invocation. Captures the
|
|
12
|
+
# caller-resolved options, the command (either an argv array for
|
|
13
|
+
# `Process.spawn` or a callable for `Process.fork`), and the optional
|
|
14
|
+
# controller block. Initializes the bookkeeping state used during
|
|
15
|
+
# {#execute} (capture map, controller stream map, helper threads, child
|
|
16
|
+
# vs parent stream tracking, default stream behavior).
|
|
17
|
+
#
|
|
18
|
+
# @private
|
|
19
|
+
#
|
|
20
|
+
# @param exec_opts [ExecService::Opts] Resolved per-call options.
|
|
21
|
+
# @param spawn_cmd [Array<String>,Proc] Either the argv to spawn, or a
|
|
22
|
+
# callable to invoke in a fork.
|
|
23
|
+
# @param block [Proc,nil] The optional controller block (only used in
|
|
24
|
+
# foreground mode).
|
|
25
|
+
#
|
|
26
|
+
def initialize(exec_opts, spawn_cmd, block)
|
|
27
|
+
@fork_func = spawn_cmd.respond_to?(:call) ? spawn_cmd : nil
|
|
28
|
+
if @fork_func && !::Process.respond_to?(:fork)
|
|
29
|
+
raise ::NotImplementedError,
|
|
30
|
+
"Executing a proc is not available because fork is not supported on the current Ruby platform"
|
|
31
|
+
end
|
|
32
|
+
@spawn_cmd = spawn_cmd.respond_to?(:call) ? nil : spawn_cmd
|
|
33
|
+
@config_opts = exec_opts.config_opts
|
|
34
|
+
@spawn_opts = exec_opts.spawn_opts
|
|
35
|
+
@captures = {}
|
|
36
|
+
@controller_streams = {}
|
|
37
|
+
@join_threads = []
|
|
38
|
+
@child_streams = []
|
|
39
|
+
@parent_streams = []
|
|
40
|
+
@block = block
|
|
41
|
+
@default_stream = @config_opts[:background] ? :null : :inherit
|
|
42
|
+
@captures_mutex = ::Mutex.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# Run the subprocess. Sets up all three standard streams, logs the
|
|
47
|
+
# command, spawns/forks, and wraps the result in a {Controller}. In
|
|
48
|
+
# background mode returns the controller immediately; in foreground mode
|
|
49
|
+
# yields the controller to the user block (closing its `:in` stream
|
|
50
|
+
# afterward), waits for completion, fires `:result_callback`, closes the
|
|
51
|
+
# controller's output streams, and returns the {Result}.
|
|
52
|
+
#
|
|
53
|
+
# @private
|
|
54
|
+
#
|
|
55
|
+
# @return [ExecService::Controller] if running in the background.
|
|
56
|
+
# @return [ExecService::Result] if running in the foreground.
|
|
57
|
+
#
|
|
58
|
+
def execute
|
|
59
|
+
setup_in_stream
|
|
60
|
+
setup_out_stream(:out)
|
|
61
|
+
setup_out_stream(:err)
|
|
62
|
+
log_command
|
|
63
|
+
controller = start_with_controller
|
|
64
|
+
return controller if @config_opts[:background]
|
|
65
|
+
begin
|
|
66
|
+
begin
|
|
67
|
+
@block&.call(controller)
|
|
68
|
+
ensure
|
|
69
|
+
controller.close_in_stream
|
|
70
|
+
end
|
|
71
|
+
result = controller.result
|
|
72
|
+
@config_opts[:result_callback]&.call(result)
|
|
73
|
+
ensure
|
|
74
|
+
controller.close_out_streams
|
|
75
|
+
end
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Emit the command line to the configured `:logger` at `:log_level`
|
|
83
|
+
# (default `Logger::INFO`). No-op if no logger is set or `:log_level` is
|
|
84
|
+
# `false`. Uses `:log_cmd` if provided, otherwise falls back to
|
|
85
|
+
# {#default_log_str}.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
#
|
|
89
|
+
def log_command
|
|
90
|
+
logger = @config_opts[:logger]
|
|
91
|
+
if logger && @config_opts[:log_level] != false
|
|
92
|
+
cmd_str = @config_opts[:log_cmd] || default_log_str
|
|
93
|
+
logger.add(@config_opts[:log_level] || ::Logger::INFO, cmd_str) if cmd_str
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# Build the default human-readable log string for this invocation,
|
|
99
|
+
# depending on whether this is a fork-of-proc, a shell string, or an argv.
|
|
100
|
+
# Strips the argv0 override (the second element of `[bin, argv0]`) when
|
|
101
|
+
# rendering an argv form so the log shows the actual binary.
|
|
102
|
+
#
|
|
103
|
+
# @return [String,nil] Log string, or nil if there is nothing to log.
|
|
104
|
+
#
|
|
105
|
+
def default_log_str
|
|
106
|
+
if @fork_func
|
|
107
|
+
"exec proc: #{@fork_func.inspect}"
|
|
108
|
+
elsif @spawn_cmd
|
|
109
|
+
if @spawn_cmd.size == 1 && @spawn_cmd.first.is_a?(::String)
|
|
110
|
+
"exec sh: #{@spawn_cmd.first.inspect}"
|
|
111
|
+
else
|
|
112
|
+
cmd_binary = @spawn_cmd.first
|
|
113
|
+
cmd_binary = cmd_binary.first if cmd_binary.is_a?(::Array)
|
|
114
|
+
"exec: #{([cmd_binary] + @spawn_cmd[1..]).inspect}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
##
|
|
120
|
+
# Start the subprocess (via {#start_process} or {#start_fork}), close the
|
|
121
|
+
# parent's references to the child-side IO ends so the child can detect
|
|
122
|
+
# EOF properly, and construct a {Controller} wrapping the resulting pid
|
|
123
|
+
# (or the spawn exception). Background mode forwards the result callback
|
|
124
|
+
# to the controller for async firing.
|
|
125
|
+
#
|
|
126
|
+
# @return [ExecService::Controller]
|
|
127
|
+
#
|
|
128
|
+
def start_with_controller
|
|
129
|
+
pid_or_exception =
|
|
130
|
+
begin
|
|
131
|
+
@fork_func ? start_fork : start_process
|
|
132
|
+
rescue ::StandardError => e
|
|
133
|
+
e
|
|
134
|
+
end
|
|
135
|
+
@child_streams.each(&:close)
|
|
136
|
+
background_callback = @config_opts[:result_callback] if @config_opts[:background]
|
|
137
|
+
Controller.new(name: @config_opts[:name],
|
|
138
|
+
controller_streams: @controller_streams,
|
|
139
|
+
captures: @captures,
|
|
140
|
+
pid_or_exception: pid_or_exception,
|
|
141
|
+
join_threads: @join_threads,
|
|
142
|
+
background_callback: background_callback,
|
|
143
|
+
captures_mutex: @captures_mutex)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# Spawn the OS process. Prepends the env hash if any was configured, and
|
|
148
|
+
# wraps the call in `Bundler.with_unbundled_env` when `:unbundle` is set
|
|
149
|
+
# and Bundler is loaded.
|
|
150
|
+
#
|
|
151
|
+
# @return [Integer] The pid of the spawned process.
|
|
152
|
+
#
|
|
153
|
+
def start_process
|
|
154
|
+
args = []
|
|
155
|
+
args << @config_opts[:env] if @config_opts[:env]
|
|
156
|
+
args.concat(@spawn_cmd)
|
|
157
|
+
if @config_opts[:unbundle] && defined?(::Bundler) && ::Bundler.respond_to?(:with_unbundled_env)
|
|
158
|
+
::Bundler.with_unbundled_env do
|
|
159
|
+
::Process.spawn(*args, @spawn_opts)
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
::Process.spawn(*args, @spawn_opts)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
##
|
|
167
|
+
# Fork a child process for {#run_fork_func}. In the parent, returns the
|
|
168
|
+
# child pid. In the child, applies env/stream setup, invokes the user
|
|
169
|
+
# proc, and exits via `Kernel.exit!` (skipping at_exit handlers) with the
|
|
170
|
+
# proc's return value, a `SystemExit` status, or -1 on uncaught
|
|
171
|
+
# exceptions.
|
|
172
|
+
#
|
|
173
|
+
# @return [Integer] The child pid (in the parent process). Does not
|
|
174
|
+
# return in the child.
|
|
175
|
+
#
|
|
176
|
+
def start_fork
|
|
177
|
+
pid = ::Process.fork
|
|
178
|
+
return pid unless pid.nil?
|
|
179
|
+
exit_code = -1
|
|
180
|
+
begin
|
|
181
|
+
setup_env_within_fork
|
|
182
|
+
setup_streams_within_fork
|
|
183
|
+
exit_code = run_fork_func
|
|
184
|
+
rescue ::SystemExit => e
|
|
185
|
+
exit_code = e.status
|
|
186
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
187
|
+
warn(([e.inspect] + e.backtrace).join("\n"))
|
|
188
|
+
ensure
|
|
189
|
+
::Kernel.exit!(exit_code)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
##
|
|
194
|
+
# Invoke the user proc inside the fork, honoring `:chdir` if given.
|
|
195
|
+
# Wrapped in `catch(:result)` so the proc may `throw :result, code` to
|
|
196
|
+
# short-circuit; otherwise the proc's return value is discarded and 0 is
|
|
197
|
+
# used.
|
|
198
|
+
#
|
|
199
|
+
# @return [Integer] The exit code to use for the forked child.
|
|
200
|
+
#
|
|
201
|
+
def run_fork_func
|
|
202
|
+
catch(:result) do
|
|
203
|
+
if @spawn_opts[:chdir]
|
|
204
|
+
::Dir.chdir(@spawn_opts[:chdir]) { @fork_func.call }
|
|
205
|
+
else
|
|
206
|
+
@fork_func.call
|
|
207
|
+
end
|
|
208
|
+
0
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
##
|
|
213
|
+
# Apply the configured `:env` hash inside the fork. If
|
|
214
|
+
# `:unsetenv_others` is set, first delete every existing variable not
|
|
215
|
+
# named in the configured env. Nil values delete; everything else is
|
|
216
|
+
# coerced to string.
|
|
217
|
+
#
|
|
218
|
+
# @return [void]
|
|
219
|
+
#
|
|
220
|
+
def setup_env_within_fork
|
|
221
|
+
env = @config_opts[:env] || {}
|
|
222
|
+
if @spawn_opts[:unsetenv_others]
|
|
223
|
+
::ENV.each_key do |k|
|
|
224
|
+
::ENV.delete(k) unless env.key?(k)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
env.each do |k, v|
|
|
228
|
+
if v.nil?
|
|
229
|
+
::ENV.delete(k.to_s)
|
|
230
|
+
else
|
|
231
|
+
::ENV[k.to_s] = v.to_s
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
##
|
|
237
|
+
# In-fork stream setup. Closes parent-side IO ends (the child no longer
|
|
238
|
+
# needs them) and reopens `$stdin`/`$stdout`/`$stderr` per the resolved
|
|
239
|
+
# spawn-options translation that {#setup_in_stream} / {#setup_out_stream}
|
|
240
|
+
# produced on the parent side.
|
|
241
|
+
#
|
|
242
|
+
# @return [void]
|
|
243
|
+
#
|
|
244
|
+
def setup_streams_within_fork
|
|
245
|
+
@parent_streams.each(&:close)
|
|
246
|
+
setup_in_stream_within_fork(@spawn_opts[:in], $stdin)
|
|
247
|
+
setup_out_stream_within_fork(@spawn_opts[:out], $stdout)
|
|
248
|
+
setup_out_stream_within_fork(@spawn_opts[:err], $stderr)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
##
|
|
252
|
+
# Reopen stdin in the fork according to the parent-resolved spawn-opt
|
|
253
|
+
# value (which may be an fd Integer, a `[path, mode]` array, a path
|
|
254
|
+
# String, `:close`, or a readable IO). Anything else is ignored.
|
|
255
|
+
#
|
|
256
|
+
# @param stream [Object] The parent-resolved spawn-opt value.
|
|
257
|
+
# @param stdstream [IO] The standard stream to reopen (typically
|
|
258
|
+
# `$stdin`).
|
|
259
|
+
# @return [void]
|
|
260
|
+
#
|
|
261
|
+
def setup_in_stream_within_fork(stream, stdstream)
|
|
262
|
+
in_stream =
|
|
263
|
+
case stream
|
|
264
|
+
when ::Integer
|
|
265
|
+
::IO.open(stream)
|
|
266
|
+
when ::Array
|
|
267
|
+
::File.open(*stream)
|
|
268
|
+
when ::String
|
|
269
|
+
::File.open(stream, "r")
|
|
270
|
+
when :close
|
|
271
|
+
:close
|
|
272
|
+
else
|
|
273
|
+
stream if stream.respond_to?(:read)
|
|
274
|
+
end
|
|
275
|
+
if in_stream == :close
|
|
276
|
+
stdstream.close
|
|
277
|
+
elsif in_stream
|
|
278
|
+
stdstream.reopen(in_stream)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
##
|
|
283
|
+
# Reopen stdout/stderr in the fork. Mirrors {#setup_in_stream_within_fork}
|
|
284
|
+
# for output: fd Integer, `[path, mode, perms]` array (delegated to
|
|
285
|
+
# {#interpret_out_array_within_fork}), path String, `:close`, or a
|
|
286
|
+
# writable IO. Sets `sync = true` on the reopened stream.
|
|
287
|
+
#
|
|
288
|
+
# @param stream [Object] The parent-resolved spawn-opt value.
|
|
289
|
+
# @param stdstream [IO] The standard stream to reopen (typically
|
|
290
|
+
# `$stdout` or `$stderr`).
|
|
291
|
+
# @return [void]
|
|
292
|
+
#
|
|
293
|
+
def setup_out_stream_within_fork(stream, stdstream)
|
|
294
|
+
out_stream =
|
|
295
|
+
case stream
|
|
296
|
+
when ::Integer
|
|
297
|
+
::IO.open(stream)
|
|
298
|
+
when ::Array
|
|
299
|
+
interpret_out_array_within_fork(stream)
|
|
300
|
+
when ::String
|
|
301
|
+
::File.open(stream, "w")
|
|
302
|
+
when :close
|
|
303
|
+
:close
|
|
304
|
+
else
|
|
305
|
+
stream if stream.respond_to?(:write)
|
|
306
|
+
end
|
|
307
|
+
if out_stream == :close
|
|
308
|
+
stdstream.close
|
|
309
|
+
elsif out_stream
|
|
310
|
+
stdstream.reopen(out_stream)
|
|
311
|
+
stdstream.sync = true
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
##
|
|
316
|
+
# Decode an array-shaped output spawn-opt inside the fork. Specifically
|
|
317
|
+
# handles `[:child, :out]` / `[:child, :err]` (alias another std stream
|
|
318
|
+
# in this child) and falls through to `File.open(*stream)` for file
|
|
319
|
+
# specifications.
|
|
320
|
+
#
|
|
321
|
+
# @param stream [Array] The array spawn-opt value.
|
|
322
|
+
# @return [IO] The IO to reopen with.
|
|
323
|
+
#
|
|
324
|
+
def interpret_out_array_within_fork(stream)
|
|
325
|
+
if stream.first == :child
|
|
326
|
+
case stream[1]
|
|
327
|
+
when :err
|
|
328
|
+
$stderr
|
|
329
|
+
when :out
|
|
330
|
+
$stdout
|
|
331
|
+
end
|
|
332
|
+
else
|
|
333
|
+
::File.open(*stream)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
##
|
|
338
|
+
# Top-level dispatch for the configured `:in` setting. Translates user
|
|
339
|
+
# syntax (Symbol / Integer / String / IO / StringIO / Array) into a call
|
|
340
|
+
# to {#setup_in_stream_of_type} or one of the array/IO interpreters.
|
|
341
|
+
# Defaults to `:inherit` (foreground) or `:null` (background).
|
|
342
|
+
#
|
|
343
|
+
# @return [void]
|
|
344
|
+
#
|
|
345
|
+
def setup_in_stream
|
|
346
|
+
setting = @config_opts[:in] || @default_stream
|
|
347
|
+
return unless setting
|
|
348
|
+
case setting
|
|
349
|
+
when ::Symbol
|
|
350
|
+
setup_in_stream_of_type(setting, [])
|
|
351
|
+
when ::Integer
|
|
352
|
+
setup_in_stream_of_type(:parent, [setting])
|
|
353
|
+
when ::String
|
|
354
|
+
setup_in_stream_of_type(:file, [setting])
|
|
355
|
+
when ::IO, ::StringIO
|
|
356
|
+
interpret_in_io(setting)
|
|
357
|
+
when ::Array
|
|
358
|
+
interpret_in_array(setting)
|
|
359
|
+
else
|
|
360
|
+
raise "Unknown value for in: #{setting.inspect}"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
##
|
|
365
|
+
# Decide how to plug an IO/StringIO `:in` value into the child: real
|
|
366
|
+
# OS-backed IOs are passed by fd, others are pumped through a copy
|
|
367
|
+
# thread (so e.g. StringIO works).
|
|
368
|
+
#
|
|
369
|
+
# @param setting [IO,StringIO] The IO supplied as the `:in` setting.
|
|
370
|
+
# @return [void]
|
|
371
|
+
#
|
|
372
|
+
def interpret_in_io(setting)
|
|
373
|
+
if setting.fileno.is_a?(::Integer)
|
|
374
|
+
setup_in_stream_of_type(:parent, [setting.fileno])
|
|
375
|
+
else
|
|
376
|
+
setup_in_stream_of_type(:copy_io, [setting])
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
##
|
|
381
|
+
# Decode an array-shaped `:in` setting. Handles `[:type, *args]`,
|
|
382
|
+
# `["path", mode?, perms?]` (file), and `[reader_io, writer_io]` (a
|
|
383
|
+
# pre-built `IO.pipe`).
|
|
384
|
+
#
|
|
385
|
+
# @param setting [Array] The array setting value.
|
|
386
|
+
# @return [void]
|
|
387
|
+
#
|
|
388
|
+
def interpret_in_array(setting)
|
|
389
|
+
if setting.first.is_a?(::Symbol)
|
|
390
|
+
setup_in_stream_of_type(setting.first, setting[1..])
|
|
391
|
+
elsif setting.first.is_a?(::String)
|
|
392
|
+
setup_in_stream_of_type(:file, setting)
|
|
393
|
+
elsif setting.size == 2 && setting.first.is_a?(::IO) && setting.last.is_a?(::IO)
|
|
394
|
+
interpret_in_pipe(*setting)
|
|
395
|
+
else
|
|
396
|
+
raise "Unknown value for in: #{setting.inspect}"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
##
|
|
401
|
+
# Wire an explicit user-provided pipe (reader, writer pair) into stdin:
|
|
402
|
+
# the reader becomes the child's stdin and is closed in the parent on
|
|
403
|
+
# spawn; the writer is closed in the parent before forking.
|
|
404
|
+
#
|
|
405
|
+
# @param reader [IO] The read end (handed to the child).
|
|
406
|
+
# @param writer [IO] The write end (closed in the parent).
|
|
407
|
+
# @return [void]
|
|
408
|
+
#
|
|
409
|
+
def interpret_in_pipe(reader, writer)
|
|
410
|
+
@spawn_opts[:in] = reader
|
|
411
|
+
@child_streams << reader
|
|
412
|
+
@parent_streams << writer
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
##
|
|
416
|
+
# Apply a stdin setup of the given symbolic type. The dispatch covers
|
|
417
|
+
# all canonical `:in` modes: controller pipe, null device, inherit-from
|
|
418
|
+
# parent, close, raw fd ("parent"), child-stream alias, literal string
|
|
419
|
+
# input, copy-from-IO thread, and file path.
|
|
420
|
+
#
|
|
421
|
+
# @param type [Symbol] The mode (`:controller`, `:null`, `:inherit`,
|
|
422
|
+
# `:close`, `:parent`, `:child`, `:string`, `:copy_io`, `:file`).
|
|
423
|
+
# @param args [Array] Mode-specific arguments.
|
|
424
|
+
# @return [void]
|
|
425
|
+
#
|
|
426
|
+
def setup_in_stream_of_type(type, args)
|
|
427
|
+
case type
|
|
428
|
+
when :controller
|
|
429
|
+
@controller_streams[:in] = make_in_pipe
|
|
430
|
+
when :null
|
|
431
|
+
make_null_stream(:in, "r")
|
|
432
|
+
when :inherit
|
|
433
|
+
@spawn_opts[:in] = :in
|
|
434
|
+
when :close
|
|
435
|
+
@spawn_opts[:in] = type
|
|
436
|
+
when :parent
|
|
437
|
+
@spawn_opts[:in] = args.first
|
|
438
|
+
when :child
|
|
439
|
+
@spawn_opts[:in] = [:child, args.first]
|
|
440
|
+
when :string
|
|
441
|
+
write_string_thread(args.first.to_s)
|
|
442
|
+
when :copy_io
|
|
443
|
+
copy_to_in_thread(args.first)
|
|
444
|
+
when :file
|
|
445
|
+
interpret_in_file(args)
|
|
446
|
+
else
|
|
447
|
+
raise "Unknown type for in: #{type.inspect}"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
##
|
|
452
|
+
# Validate and apply a `:file`-typed `:in` setup. Forces read-only mode
|
|
453
|
+
# and rejects extra args; only a single path String is accepted (mode
|
|
454
|
+
# and perms are deliberately not user-configurable on stdin).
|
|
455
|
+
#
|
|
456
|
+
# @param args [Array<String>] One-element array containing the path.
|
|
457
|
+
# @return [void]
|
|
458
|
+
#
|
|
459
|
+
def interpret_in_file(args)
|
|
460
|
+
raise "Expected only file name for in" unless args.size == 1 && args.first.is_a?(::String)
|
|
461
|
+
@spawn_opts[:in] = args + [::File::RDONLY]
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
##
|
|
465
|
+
# Top-level dispatch for `:out` or `:err`. Symmetric to
|
|
466
|
+
# {#setup_in_stream} but with the additional `:tee` and `:capture` modes
|
|
467
|
+
# available (and `:string` / `:copy_io` for input not present here).
|
|
468
|
+
#
|
|
469
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
470
|
+
# @return [void]
|
|
471
|
+
#
|
|
472
|
+
def setup_out_stream(key)
|
|
473
|
+
setting = @config_opts[key] || @default_stream
|
|
474
|
+
case setting
|
|
475
|
+
when ::Symbol
|
|
476
|
+
setup_out_stream_of_type(key, setting, [])
|
|
477
|
+
when ::Integer
|
|
478
|
+
setup_out_stream_of_type(key, :parent, [setting])
|
|
479
|
+
when ::String
|
|
480
|
+
setup_out_stream_of_type(key, :file, [setting])
|
|
481
|
+
when ::IO, ::StringIO
|
|
482
|
+
interpret_out_io(key, setting)
|
|
483
|
+
when ::Array
|
|
484
|
+
interpret_out_array(key, setting)
|
|
485
|
+
else
|
|
486
|
+
raise "Unknown value for #{key}: #{setting.inspect}"
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
##
|
|
491
|
+
# Output counterpart to {#interpret_in_io}: real-fd IOs hook directly,
|
|
492
|
+
# everything else gets a copy-from-pipe thread.
|
|
493
|
+
#
|
|
494
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
495
|
+
# @param setting [IO,StringIO] The user-supplied IO.
|
|
496
|
+
# @return [void]
|
|
497
|
+
#
|
|
498
|
+
def interpret_out_io(key, setting)
|
|
499
|
+
if setting.fileno.is_a?(::Integer)
|
|
500
|
+
setup_out_stream_of_type(key, :parent, [setting.fileno])
|
|
501
|
+
else
|
|
502
|
+
setup_out_stream_of_type(key, :copy_io, [setting])
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
##
|
|
507
|
+
# Decode an array-shaped `:out`/`:err` setting: `[:type, *args]`,
|
|
508
|
+
# `["path", mode?, perms?]`, or a `[reader, writer]` pipe pair.
|
|
509
|
+
#
|
|
510
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
511
|
+
# @param setting [Array] The array setting value.
|
|
512
|
+
# @return [void]
|
|
513
|
+
#
|
|
514
|
+
def interpret_out_array(key, setting)
|
|
515
|
+
if setting.first.is_a?(::Symbol)
|
|
516
|
+
setup_out_stream_of_type(key, setting.first, setting[1..])
|
|
517
|
+
elsif setting.first.is_a?(::String)
|
|
518
|
+
setup_out_stream_of_type(key, :file, setting)
|
|
519
|
+
elsif setting.size == 2 && setting.first.is_a?(::IO) && setting.last.is_a?(::IO)
|
|
520
|
+
interpret_out_pipe(key, *setting)
|
|
521
|
+
else
|
|
522
|
+
raise "Unknown value for #{key}: #{setting.inspect}"
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
##
|
|
527
|
+
# Output counterpart to {#interpret_in_pipe}. The writer becomes the
|
|
528
|
+
# child's output stream; the reader is preserved in the parent for the
|
|
529
|
+
# caller to use, and is closed there at execution-end cleanup.
|
|
530
|
+
#
|
|
531
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
532
|
+
# @param reader [IO] The read end (kept in parent).
|
|
533
|
+
# @param writer [IO] The write end (handed to the child).
|
|
534
|
+
# @return [void]
|
|
535
|
+
#
|
|
536
|
+
def interpret_out_pipe(key, reader, writer)
|
|
537
|
+
@spawn_opts[key] = writer
|
|
538
|
+
@child_streams << writer
|
|
539
|
+
@parent_streams << reader
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
##
|
|
543
|
+
# Apply an `:out` or `:err` setup of the given symbolic type. Covers
|
|
544
|
+
# controller pipe, null, inherit, close/swap-with-other-stream, raw fd,
|
|
545
|
+
# child-alias, capture-to-string, copy-to-IO thread, file, and tee.
|
|
546
|
+
#
|
|
547
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
548
|
+
# @param type [Symbol] The mode.
|
|
549
|
+
# @param args [Array] Mode-specific arguments.
|
|
550
|
+
# @return [void]
|
|
551
|
+
#
|
|
552
|
+
def setup_out_stream_of_type(key, type, args)
|
|
553
|
+
case type
|
|
554
|
+
when :controller
|
|
555
|
+
@controller_streams[key] = make_out_pipe(key)
|
|
556
|
+
when :null
|
|
557
|
+
make_null_stream(key, "w")
|
|
558
|
+
when :inherit
|
|
559
|
+
@spawn_opts[key] = key
|
|
560
|
+
when :close, :out, :err
|
|
561
|
+
@spawn_opts[key] = type
|
|
562
|
+
when :parent
|
|
563
|
+
@spawn_opts[key] = args.first
|
|
564
|
+
when :child
|
|
565
|
+
@spawn_opts[key] = [:child, args.first]
|
|
566
|
+
when :capture
|
|
567
|
+
capture_stream_thread(key)
|
|
568
|
+
when :copy_io
|
|
569
|
+
copy_from_out_thread(key, args.first)
|
|
570
|
+
when :file
|
|
571
|
+
interpret_out_file(key, args)
|
|
572
|
+
when :tee
|
|
573
|
+
interpret_out_tee(key, args)
|
|
574
|
+
else
|
|
575
|
+
raise "Unknown type for #{key}: #{type.inspect}"
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
##
|
|
580
|
+
# Validate and apply a `:file`-typed `:out`/`:err` setup. Accepts one to
|
|
581
|
+
# three args (`path`, optional `mode`, optional `perms`); collapses the
|
|
582
|
+
# single-path case to a bare String spawn-opt for `Process.spawn`'s
|
|
583
|
+
# canonical form.
|
|
584
|
+
#
|
|
585
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
586
|
+
# @param args [Array] `[path, mode?, perms?]`.
|
|
587
|
+
# @return [void]
|
|
588
|
+
#
|
|
589
|
+
def interpret_out_file(key, args)
|
|
590
|
+
raise "Expected file name for #{key}" if args.empty? || !args.first.is_a?(::String)
|
|
591
|
+
raise "Too many file arguments for #{key}" if args.size > 3
|
|
592
|
+
@spawn_opts[key] = args.size == 1 ? args.first : args
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
##
|
|
596
|
+
# Apply a `:tee` setup. Pulls an optional trailing options Hash off the
|
|
597
|
+
# arg list, builds a pipe (the child writes here, the tee thread reads
|
|
598
|
+
# from it), interprets each remaining arg into a `[sink_io, on_done]`
|
|
599
|
+
# pair via {#interpret_out_tee_arguments}, and starts the fan-out
|
|
600
|
+
# thread.
|
|
601
|
+
#
|
|
602
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
603
|
+
# @param args [Array] Sink specs followed by an optional options Hash.
|
|
604
|
+
# @return [void]
|
|
605
|
+
#
|
|
606
|
+
def interpret_out_tee(key, args)
|
|
607
|
+
opts = args.last.is_a?(::Hash) ? args.pop : {}
|
|
608
|
+
reader = make_out_pipe(key)
|
|
609
|
+
sinks = interpret_out_tee_arguments(key, args)
|
|
610
|
+
tee_runner(key, reader, sinks, opts[:buffer_size] || 65_536)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
##
|
|
614
|
+
# Resolve each tee-arg into a `[sink_io, on_done]` pair, where
|
|
615
|
+
# `on_done` is one of `nil` (leave open), `:close` (close the IO when
|
|
616
|
+
# this sink finishes), or `:capture` (snapshot the StringIO into the
|
|
617
|
+
# captures hash).
|
|
618
|
+
#
|
|
619
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
620
|
+
# @param args [Array] The sink specs to interpret.
|
|
621
|
+
# @return [Array<Array>] One `[io, on_done]` pair per sink.
|
|
622
|
+
#
|
|
623
|
+
def interpret_out_tee_arguments(key, args)
|
|
624
|
+
args.map do |arg|
|
|
625
|
+
case arg
|
|
626
|
+
when :inherit
|
|
627
|
+
[key == :err ? $stderr : $stdout, nil]
|
|
628
|
+
when :capture
|
|
629
|
+
[::StringIO.new, :capture]
|
|
630
|
+
when :controller
|
|
631
|
+
tee_sink_for_controller(key)
|
|
632
|
+
when ::IO, ::StringIO
|
|
633
|
+
[arg, nil]
|
|
634
|
+
when ::String
|
|
635
|
+
[::File.open(arg, "w"), :close]
|
|
636
|
+
when ::Array
|
|
637
|
+
tee_sink_for_array(key, arg)
|
|
638
|
+
else
|
|
639
|
+
raise "Unknown value for #{key} tee argument: #{arg.inspect}"
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
##
|
|
645
|
+
# Build a tee sink for the `:controller` case: an internal pipe whose
|
|
646
|
+
# read end is exposed via the controller, and whose write end is closed
|
|
647
|
+
# by the tee thread when the sink completes.
|
|
648
|
+
#
|
|
649
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
650
|
+
# @return [Array] `[writer_io, :close]`.
|
|
651
|
+
#
|
|
652
|
+
def tee_sink_for_controller(key)
|
|
653
|
+
@controller_streams[key], writer = ::IO.pipe
|
|
654
|
+
writer.sync = true
|
|
655
|
+
[writer, :close]
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
##
|
|
659
|
+
# Build a tee sink from an Array spec. Two recognized shapes:
|
|
660
|
+
# * `[:autoclose, io]` or `[some_io, io]` — use `io` and close it at
|
|
661
|
+
# end. (The first form is a bit historical; both branches simply
|
|
662
|
+
# take `arg.last` as the sink and mark it for close.)
|
|
663
|
+
# * `[path, mode?, perms?]` (optionally prefixed with `:file`) —
|
|
664
|
+
# opened as a file with default mode `"w"`.
|
|
665
|
+
#
|
|
666
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
667
|
+
# @param arg [Array] The array sink spec.
|
|
668
|
+
# @return [Array] `[io, :close]`.
|
|
669
|
+
#
|
|
670
|
+
def tee_sink_for_array(key, arg)
|
|
671
|
+
if arg.size == 2 &&
|
|
672
|
+
arg.last.is_a?(::IO) &&
|
|
673
|
+
(arg.first == :autoclose || arg.first.is_a?(::IO))
|
|
674
|
+
[arg.last, :close]
|
|
675
|
+
else
|
|
676
|
+
arg = arg[1..] if arg.first == :file
|
|
677
|
+
if arg.empty? || !arg.first.is_a?(::String)
|
|
678
|
+
raise "Expected file name for #{key} tee argument"
|
|
679
|
+
end
|
|
680
|
+
raise "Too many file arguments for #{key} tee argument" if arg.size > 3
|
|
681
|
+
arg += ["w"] if arg.size == 1
|
|
682
|
+
[::File.open(*arg), :close]
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
##
|
|
687
|
+
# Spawn the fan-out thread that drives the tee. Each sink is tracked as
|
|
688
|
+
# `[io, buffer, write_method, on_done]`. The loop alternates an
|
|
689
|
+
# `IO.select` wait, a non-blocking read from the source pipe into every
|
|
690
|
+
# sink's buffer, and a non-blocking write from each sink's buffer into
|
|
691
|
+
# its IO. Sinks drop out of the list when they finish (EOF reached and
|
|
692
|
+
# buffer drained, or the sink errored). The thread is registered with
|
|
693
|
+
# `@join_threads` so {Controller#cleanup} waits on it before producing
|
|
694
|
+
# the {Result}.
|
|
695
|
+
#
|
|
696
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
697
|
+
# @param reader [IO] The pipe read end attached to the child's output.
|
|
698
|
+
# @param sinks [Array<Array>] `[io, on_done]` pairs from
|
|
699
|
+
# {#interpret_out_tee_arguments}.
|
|
700
|
+
# @param buffer_size [Integer] Per-sink memory buffer cap.
|
|
701
|
+
# @return [Thread]
|
|
702
|
+
#
|
|
703
|
+
def tee_runner(key, reader, sinks, buffer_size)
|
|
704
|
+
@join_threads << ::Thread.new do
|
|
705
|
+
sinks.map! { |io, on_done| [io, ::String.new, :write_nonblock, on_done] }
|
|
706
|
+
until sinks.empty?
|
|
707
|
+
tee_wait_for_streams(reader, sinks)
|
|
708
|
+
reader = tee_read_stream(reader, sinks, buffer_size)
|
|
709
|
+
tee_write_streams(sinks, key, reader.nil?)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
##
|
|
715
|
+
# Block until either the reader has data or some sink has buffered
|
|
716
|
+
# bytes ready to flush, using `IO.select`.
|
|
717
|
+
#
|
|
718
|
+
# @param reader [IO,nil] The source pipe; nil once EOF was reached.
|
|
719
|
+
# @param sinks [Array<Array>] Per-sink state tuples.
|
|
720
|
+
# @return [void]
|
|
721
|
+
#
|
|
722
|
+
def tee_wait_for_streams(reader, sinks)
|
|
723
|
+
read_select = reader && [reader]
|
|
724
|
+
write_select = []
|
|
725
|
+
sinks.each do |io, buffer, _write_method, _on_done|
|
|
726
|
+
write_select << io unless buffer.empty?
|
|
727
|
+
end
|
|
728
|
+
::IO.select(read_select, write_select)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
##
|
|
732
|
+
# Read up to the available headroom (`buffer_size` minus the largest
|
|
733
|
+
# in-flight buffer, see {#tee_amount_to_read}) from the source pipe and
|
|
734
|
+
# append the data into every sink's buffer. Returns `nil` to signal EOF
|
|
735
|
+
# (or any unexpected error) so the caller can flag read-complete; on
|
|
736
|
+
# `WaitReadable` simply returns the reader to retry next iteration.
|
|
737
|
+
#
|
|
738
|
+
# @param reader [IO,nil] The source pipe, or nil if EOF already
|
|
739
|
+
# reached.
|
|
740
|
+
# @param sinks [Array<Array>] Per-sink state tuples.
|
|
741
|
+
# @param buffer_size [Integer] Per-sink buffer cap.
|
|
742
|
+
# @return [IO,nil] The reader to use next iteration, or nil at EOF.
|
|
743
|
+
#
|
|
744
|
+
def tee_read_stream(reader, sinks, buffer_size)
|
|
745
|
+
return nil if reader.nil?
|
|
746
|
+
max = tee_amount_to_read(sinks, buffer_size)
|
|
747
|
+
return reader unless max.positive?
|
|
748
|
+
begin
|
|
749
|
+
data = reader.read_nonblock(max)
|
|
750
|
+
unless data.empty?
|
|
751
|
+
sinks.each { |_io, buffer, _write_method, _on_done| buffer << data }
|
|
752
|
+
end
|
|
753
|
+
reader
|
|
754
|
+
rescue ::IO::WaitReadable
|
|
755
|
+
reader
|
|
756
|
+
rescue ::StandardError
|
|
757
|
+
reader.close rescue nil # rubocop:disable Style/RescueModifier
|
|
758
|
+
nil
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
##
|
|
763
|
+
# Drive each sink one step: write whatever it can, mutating the
|
|
764
|
+
# write-method in the tuple if a fallback is needed (see
|
|
765
|
+
# {#tee_write_one_stream}). Drop sinks that have finished, running
|
|
766
|
+
# their `on_done` action (close the io, or capture its String into the
|
|
767
|
+
# captures hash).
|
|
768
|
+
#
|
|
769
|
+
# @param sinks [Array<Array>] Per-sink state tuples (mutated in-place).
|
|
770
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
771
|
+
# @param read_complete [Boolean] True once the source pipe hit EOF.
|
|
772
|
+
# @return [void]
|
|
773
|
+
#
|
|
774
|
+
def tee_write_streams(sinks, key, read_complete)
|
|
775
|
+
sinks.delete_if do |sink|
|
|
776
|
+
io, buffer, write_method, on_done = sink
|
|
777
|
+
done, write_method = tee_write_one_stream(io, buffer, write_method, read_complete)
|
|
778
|
+
sink[2] = write_method
|
|
779
|
+
if done
|
|
780
|
+
case on_done
|
|
781
|
+
when :close
|
|
782
|
+
io.close rescue nil # rubocop:disable Style/RescueModifier
|
|
783
|
+
when :capture
|
|
784
|
+
@captures_mutex.synchronize do
|
|
785
|
+
@captures[key] = io.string
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
done
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
##
|
|
794
|
+
# Attempt one nonblocking write from a sink's buffer. If the sink
|
|
795
|
+
# doesn't support `write_nonblock` (some StringIOs / pseudo-IOs), fall
|
|
796
|
+
# back permanently to plain `write`. Treats `WaitWritable`/`EINTR` as
|
|
797
|
+
# "try again later". Returns `[done, write_method]`: `done` is true
|
|
798
|
+
# when the sink should be removed (buffer empty and source EOF, or
|
|
799
|
+
# unrecoverable error).
|
|
800
|
+
#
|
|
801
|
+
# @param io [IO,StringIO] The sink IO.
|
|
802
|
+
# @param buffer [String] Pending bytes (mutated in-place).
|
|
803
|
+
# @param write_method [Symbol] `:write_nonblock` or `:write`.
|
|
804
|
+
# @param read_complete [Boolean] True if the source pipe is done.
|
|
805
|
+
# @return [Array] `[done, write_method]`.
|
|
806
|
+
#
|
|
807
|
+
def tee_write_one_stream(io, buffer, write_method, read_complete)
|
|
808
|
+
return [read_complete, write_method] if buffer.empty?
|
|
809
|
+
begin
|
|
810
|
+
bytes = io.send(write_method, buffer)
|
|
811
|
+
buffer.slice!(0, bytes)
|
|
812
|
+
[false, write_method]
|
|
813
|
+
rescue ::IO::WaitWritable, ::Errno::EINTR
|
|
814
|
+
[false, write_method]
|
|
815
|
+
rescue ::Errno::EBADF, ::NoMethodError
|
|
816
|
+
raise if write_method == :write
|
|
817
|
+
[false, :write]
|
|
818
|
+
rescue ::StandardError
|
|
819
|
+
[true, write_method]
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
##
|
|
824
|
+
# Compute how many bytes the next read may pull, so that no sink's
|
|
825
|
+
# buffer exceeds `buffer_size`. Returns the headroom against the
|
|
826
|
+
# currently-fullest buffer (which may be zero or negative — the caller
|
|
827
|
+
# treats non-positive values as "skip the read this round").
|
|
828
|
+
#
|
|
829
|
+
# @param sink_info [Array<Array>] Per-sink state tuples.
|
|
830
|
+
# @param buffer_size [Integer] Per-sink buffer cap.
|
|
831
|
+
# @return [Integer] Bytes to read this iteration.
|
|
832
|
+
#
|
|
833
|
+
def tee_amount_to_read(sink_info, buffer_size)
|
|
834
|
+
maxbuff = 0
|
|
835
|
+
sink_info.each do |_sink, buffer, _meth|
|
|
836
|
+
maxbuff = buffer.size if buffer.size > maxbuff
|
|
837
|
+
end
|
|
838
|
+
buffer_size - maxbuff
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
##
|
|
842
|
+
# Open `File::NULL` in the given mode and wire it to the named
|
|
843
|
+
# spawn-opt key. Tracked as a child stream so it gets closed in the
|
|
844
|
+
# parent after spawn.
|
|
845
|
+
#
|
|
846
|
+
# @param key [Symbol] One of `:in`, `:out`, `:err`.
|
|
847
|
+
# @param mode [String] File open mode (`"r"` for `:in`, `"w"` for the
|
|
848
|
+
# others).
|
|
849
|
+
# @return [void]
|
|
850
|
+
#
|
|
851
|
+
def make_null_stream(key, mode)
|
|
852
|
+
f = ::File.open(::File::NULL, mode)
|
|
853
|
+
@spawn_opts[key] = f
|
|
854
|
+
@child_streams << f
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
##
|
|
858
|
+
# Build a stdin pipe: the read end goes to the child (and is closed in
|
|
859
|
+
# the parent post-spawn), the write end is exposed to the parent (and
|
|
860
|
+
# is closed there during execution-end cleanup). The writer is set to
|
|
861
|
+
# `sync = true` so caller writes don't buffer indefinitely.
|
|
862
|
+
#
|
|
863
|
+
# @return [IO] The write end (parent-side).
|
|
864
|
+
#
|
|
865
|
+
def make_in_pipe
|
|
866
|
+
r, w = ::IO.pipe
|
|
867
|
+
@spawn_opts[:in] = r
|
|
868
|
+
@child_streams << r
|
|
869
|
+
@parent_streams << w
|
|
870
|
+
w.sync = true
|
|
871
|
+
w
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
##
|
|
875
|
+
# Build an output pipe for `:out` or `:err`: write end goes to the
|
|
876
|
+
# child, read end is exposed parent-side.
|
|
877
|
+
#
|
|
878
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
879
|
+
# @return [IO] The read end (parent-side).
|
|
880
|
+
#
|
|
881
|
+
def make_out_pipe(key)
|
|
882
|
+
r, w = ::IO.pipe
|
|
883
|
+
@spawn_opts[key] = w
|
|
884
|
+
@child_streams << w
|
|
885
|
+
@parent_streams << r
|
|
886
|
+
r
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
##
|
|
890
|
+
# Spawn a helper thread that pumps a literal String into the child's
|
|
891
|
+
# stdin and then closes the pipe (so the child sees EOF). Registered
|
|
892
|
+
# with `@join_threads`.
|
|
893
|
+
#
|
|
894
|
+
# @param string [String] The bytes to send.
|
|
895
|
+
# @return [Thread]
|
|
896
|
+
#
|
|
897
|
+
def write_string_thread(string)
|
|
898
|
+
stream = make_in_pipe
|
|
899
|
+
@join_threads << ::Thread.new do
|
|
900
|
+
stream.write string
|
|
901
|
+
ensure
|
|
902
|
+
stream.close
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
##
|
|
907
|
+
# Spawn a helper thread that copies from a user-supplied readable
|
|
908
|
+
# object (typically a non-fd-backed IO like StringIO) into the child's
|
|
909
|
+
# stdin pipe, closing the pipe at end. Registered with `@join_threads`.
|
|
910
|
+
#
|
|
911
|
+
# @param io [IO,StringIO] The source.
|
|
912
|
+
# @return [Thread]
|
|
913
|
+
#
|
|
914
|
+
def copy_to_in_thread(io)
|
|
915
|
+
stream = make_in_pipe
|
|
916
|
+
@join_threads << ::Thread.new do
|
|
917
|
+
::IO.copy_stream(io, stream)
|
|
918
|
+
ensure
|
|
919
|
+
stream.close
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
##
|
|
924
|
+
# Spawn a helper thread that copies from the child's `:out`/`:err`
|
|
925
|
+
# pipe into a user-supplied writable object (typically a non-fd-backed
|
|
926
|
+
# IO like StringIO), closing the pipe at end. Registered with
|
|
927
|
+
# `@join_threads`.
|
|
928
|
+
#
|
|
929
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
930
|
+
# @param io [IO,StringIO] The destination.
|
|
931
|
+
# @return [Thread]
|
|
932
|
+
#
|
|
933
|
+
def copy_from_out_thread(key, io)
|
|
934
|
+
stream = make_out_pipe(key)
|
|
935
|
+
@join_threads << ::Thread.new do
|
|
936
|
+
::IO.copy_stream(stream, io)
|
|
937
|
+
ensure
|
|
938
|
+
stream.close
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
##
|
|
943
|
+
# Spawn a helper thread that drains the child's `:out`/`:err` pipe
|
|
944
|
+
# entirely into a String and stores it in `@captures` (under the mutex
|
|
945
|
+
# shared with the controller). Registered with `@join_threads`.
|
|
946
|
+
#
|
|
947
|
+
# @param key [Symbol] Either `:out` or `:err`.
|
|
948
|
+
# @return [Thread]
|
|
949
|
+
#
|
|
950
|
+
def capture_stream_thread(key)
|
|
951
|
+
stream = make_out_pipe(key)
|
|
952
|
+
@join_threads << ::Thread.new do
|
|
953
|
+
data = stream.read
|
|
954
|
+
@captures_mutex.synchronize do
|
|
955
|
+
@captures[key] = data
|
|
956
|
+
end
|
|
957
|
+
ensure
|
|
958
|
+
stream.close
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
end
|