exec_service 0.0.0 → 0.1.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.
@@ -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