process_executer 4.0.3 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80aa4031ab96fafeb8d1c1126fe4646145b59208221aec2188f21904cede7b7d
4
- data.tar.gz: 1007e6fb46320b51bfe9d216a0ef9646d357bcff20c60313c32885b31db8b7f2
3
+ metadata.gz: 63acfcf3541790f98d140feac5a9badc901f8b4e475b3c97d125f6b549556bfc
4
+ data.tar.gz: 7c42a5cfa8b66fea530be0821443a974c1fabc1002fc2b3f2f35e533812c9c6e
5
5
  SHA512:
6
- metadata.gz: c2cb8d71448b21f256d03011b20492eb8f5b766d668c564a3a836e01fe4aa68bdbf2d5135f8461d576cccb54ca3e189f59999f93aa51d33dcd65bb00b3be15f9
7
- data.tar.gz: d6cb1188669658fed64b92f5bdce4262bb87b1e38f6fff710cfe9e15773d753b70e6d4aff9b73def4ff1e823428ddf7dd8356518c33deccfb88de3658f706787
6
+ metadata.gz: b8d54aa2b9c5e1c153a4c92dbaeec6d96c3b80f31f6517ad2026519d070eff6d6c5b6fdc447cfbff481a6c15599c06ace734068f784e1f1ca8ba78831e84e0d7
7
+ data.tar.gz: fef70d1e6342139152e498dd72572f591f5f330f8617e7ad0603898638c08db8e2ec09aeb302e486949432d80a7635611654959869314946df13c1bf87ed4dd0
data/.editorconfig ADDED
@@ -0,0 +1,10 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ charset = utf-8
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+
9
+ [*.md]
10
+ trim_trailing_whitespace = false
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "4.0.3"
2
+ ".": "4.1.0"
3
3
  }
data/.rubocop.yml CHANGED
@@ -5,3 +5,16 @@ AllCops:
5
5
  # Pin this project to Ruby 3.1 in case the shared config above is upgraded to 3.2
6
6
  # or later.
7
7
  TargetRubyVersion: 3.1
8
+
9
+ # The default `native` style expects CRLF on Windows, but this project's files
10
+ # are LF everywhere. Pin the style so RuboCop gives the same result on Windows
11
+ # as it does on Linux and macOS.
12
+ Layout/EndOfLine:
13
+ EnforcedStyle: lf
14
+
15
+ # MonitoredPipe grew past the default limit when the close deadline handling
16
+ # was added for issue #164. Its pipe lifecycle logic (open, monitor, drain,
17
+ # close) is cohesive and would not be clearer split across classes.
18
+ Metrics/ClassLength:
19
+ Exclude:
20
+ - "lib/process_executer/monitored_pipe.rb"
@@ -0,0 +1,3 @@
1
+ {
2
+ "recommendations": ["editorconfig.editorconfig"]
3
+ }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to the process_executer gem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.1.0](https://github.com/main-branch/process_executer/compare/v4.0.4...v4.1.0) (2026-08-30)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * Make timeout_after bound execution even when descendants hold stdout/stderr open ([a1298d3](https://github.com/main-branch/process_executer/commit/a1298d3f98fd8e57f58f50a0566dd3be78079189))
14
+ * **monitored_pipe:** Close acquired resources when #initialize fails partway ([8f10a86](https://github.com/main-branch/process_executer/commit/8f10a86b42c4d72f0f34004071cfeb91116d6bd4)), closes [#178](https://github.com/main-branch/process_executer/issues/178)
15
+ * **monitored_pipe:** Do not hold the mutex while writing to the pipe ([91d7fe8](https://github.com/main-branch/process_executer/commit/91d7fe88b9950d24f8c28ccbea400be0063ec99a)), closes [#165](https://github.com/main-branch/process_executer/issues/165)
16
+ * **monitored_pipe:** Handle a destination raising a non-StandardError ([a7de23b](https://github.com/main-branch/process_executer/commit/a7de23b5699ef4d32518e988ac76dd6a957277e3)), closes [#170](https://github.com/main-branch/process_executer/issues/170)
17
+ * Replace the monitor thread's 1ms poll with an event-driven wakeup pipe ([44068a3](https://github.com/main-branch/process_executer/commit/44068a35f4f87de9b7b46ce1deffda636f7c1138))
18
+ * Return a timed out result when the timeout races Process.wait2 reaping the child ([84a6a11](https://github.com/main-branch/process_executer/commit/84a6a11df7d736f26d98700e90a76f5d65e2d35c)), closes [#171](https://github.com/main-branch/process_executer/issues/171)
19
+ * **run_with_capture:** Capture combined [:out, :err] redirection into stdout ([17199b8](https://github.com/main-branch/process_executer/commit/17199b8ea332278680c46accce072763f0d67a4a)), closes [#167](https://github.com/main-branch/process_executer/issues/167)
20
+ * **run:** Close created pipes and keep the original error when pipe creation fails ([909eaa1](https://github.com/main-branch/process_executer/commit/909eaa177fa53ee4cd4e773595a8658ea94ca6a4)), closes [#166](https://github.com/main-branch/process_executer/issues/166)
21
+ * **run:** Stop mutating caller options and closing caller-owned pipes ([96d922b](https://github.com/main-branch/process_executer/commit/96d922b4bff5586189b30d9a1f83b0d47159710f)), closes [#169](https://github.com/main-branch/process_executer/issues/169)
22
+ * Validate options merged via Options::Base#merge! ([#173](https://github.com/main-branch/process_executer/issues/173)) ([da0a956](https://github.com/main-branch/process_executer/commit/da0a956bcb085378b1315dafb87351174500c966))
23
+
24
+
25
+ ### Other Changes
26
+
27
+ * Add failing tests for merge! bypassing option validation ([#173](https://github.com/main-branch/process_executer/issues/173)) ([325f16e](https://github.com/main-branch/process_executer/commit/325f16e589821571f3250e86a82f51f0e6b70f9a))
28
+ * Add failing tests for the monitor thread 1ms polling loop ([#172](https://github.com/main-branch/process_executer/issues/172)) ([e202909](https://github.com/main-branch/process_executer/commit/e202909f99074a8ceb331f13290c224c35904e7a))
29
+ * Add failing tests for the timeout vs Process.wait2 reap race ([#171](https://github.com/main-branch/process_executer/issues/171)) ([647739a](https://github.com/main-branch/process_executer/commit/647739ad7233059aafb54dad70eef14998892c5d))
30
+ * Add failing tests for timeout_after not bounding execution ([#164](https://github.com/main-branch/process_executer/issues/164)) ([8e5115c](https://github.com/main-branch/process_executer/commit/8e5115c9236347e77ba6e6d0631f5f1bfe826b26))
31
+ * **coverage:** Only enforce code coverage on MRI builds not on Windows ([160815e](https://github.com/main-branch/process_executer/commit/160815ecb091d1f155406f9a1b37f73e13690627))
32
+ * Enforce LF line endings on all platforms ([e94bfea](https://github.com/main-branch/process_executer/commit/e94bfeaf4bd93506090277b4020859cd23dfc625))
33
+ * Explain stdin redirection via the in: option ([f7bded7](https://github.com/main-branch/process_executer/commit/f7bded743a204a99c7f6e272b0330d4ba1e4a037)), closes [#159](https://github.com/main-branch/process_executer/issues/159)
34
+ * **monitored_pipe:** Accept the documented IOError and confine failures ([002274d](https://github.com/main-branch/process_executer/commit/002274d421afc84c7c441c6f56dbbc32506e1de2)), closes [#182](https://github.com/main-branch/process_executer/issues/182)
35
+ * **monitored_pipe:** Add a failing test for the #write deadlock ([9826558](https://github.com/main-branch/process_executer/commit/9826558035b03fe7becdc729053519141ba8d00d))
36
+ * **monitored_pipe:** Add failing tests for a destination raising a non-StandardError ([0177329](https://github.com/main-branch/process_executer/commit/0177329fb47f3c185d9ea768f9f80028c54fb7ab))
37
+ * **monitored_pipe:** Add failing tests for resource leaks when #initialize fails ([b2e3151](https://github.com/main-branch/process_executer/commit/b2e31519ea7a38f774b90f6e9e1f17fa0b31baa3)), closes [#178](https://github.com/main-branch/process_executer/issues/178)
38
+ * **monitored_pipe:** Make the writer-exception race deterministic ([c72f1e4](https://github.com/main-branch/process_executer/commit/c72f1e400ef3d78e4b092ecde0d0704cf79ebc36)), closes [#182](https://github.com/main-branch/process_executer/issues/182)
39
+ * **monitored_pipe:** Stub the monitor loop on the class, not the live instance ([799177f](https://github.com/main-branch/process_executer/commit/799177f6046d661013ce6b68b19c6b220294d00f))
40
+ * **process-spawn:** Check exit status and blocking, and add a subspawn backend ([99021d2](https://github.com/main-branch/process_executer/commit/99021d2144a21210f793b7ae94d36875eef504e6))
41
+ * **process-spawn:** Run the spawn tests against the subspawn backend ([573086f](https://github.com/main-branch/process_executer/commit/573086f194e790f25d916de32bb5e94437a5ae86))
42
+ * **rake:** Skip RuboCop in the default task on TruffleRuby ([3d2a118](https://github.com/main-branch/process_executer/commit/3d2a1186d65e76f170f5428d611822d95d8fdc2d))
43
+ * Release the hardening changes as v4.1.0 instead of v4.0.5 ([70b6327](https://github.com/main-branch/process_executer/commit/70b63278f334bd8b714c2fc28ded1520921add09))
44
+ * **run_with_capture:** Add failing tests for combined [:out, :err] redirection ([61e97cb](https://github.com/main-branch/process_executer/commit/61e97cb09c816cbe30d81dc33f268accf76ab396))
45
+ * **run:** Add failing tests for options mutation and caller-owned pipe closing ([cf32615](https://github.com/main-branch/process_executer/commit/cf326156381fe81562597906d43134a08ebbef17))
46
+ * **run:** Add failing tests for pipe creation failure in Run#call ([21521d9](https://github.com/main-branch/process_executer/commit/21521d9a9b5141d9bb6d22fb5fd3e7de0e497ca2))
47
+ * **run:** Update the JRuby expectation for an invalid chdir path ([2712f0b](https://github.com/main-branch/process_executer/commit/2712f0bdf060fa1831edb428183283dec5f2727a))
48
+ * Use RbConfig.ruby to locate the Ruby interpreter ([82e65e2](https://github.com/main-branch/process_executer/commit/82e65e202a4f884a3283b4af00fcd2b5eda3a394))
49
+
50
+ ## [4.0.4](https://github.com/main-branch/process_executer/compare/v4.0.3...v4.0.4) (2026-04-24)
51
+
52
+
53
+ ### Bug Fixes
54
+
55
+ * **rubocop:** Fix offenses introduced by new rubocop cops ([b21ca99](https://github.com/main-branch/process_executer/commit/b21ca991641784146b0c92c1dd3205fc9d2c0f4f))
56
+
57
+
58
+ ### Other Changes
59
+
60
+ * **dependencies:** Update dependencies for all GitHub Actions workflows ([3524dbf](https://github.com/main-branch/process_executer/commit/3524dbf8b5945c233be96381f4555407b537f236))
61
+
8
62
  ## [4.0.3](https://github.com/main-branch/process_executer/compare/v4.0.2...v4.0.3) (2026-01-04)
9
63
 
10
64
 
data/README.md CHANGED
@@ -26,6 +26,7 @@ then click the "Documentation" link.
26
26
  - [Usage](#usage)
27
27
  - [Key methods](#key-methods)
28
28
  - [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
29
+ - [Redirecting stdin](#redirecting-stdin)
29
30
  - [Encoding](#encoding)
30
31
  - [Encoding summary](#encoding-summary)
31
32
  - [Encoding details](#encoding-details)
@@ -119,6 +120,25 @@ supports these additional types of destinations:
119
120
  be any value that `MonitoredPipe` itself supports (including another tee or
120
121
  MonitoredPipe).
121
122
 
123
+ ### Redirecting stdin
124
+
125
+ The `in:` option is passed directly to `Process.spawn` and is not wrapped in a
126
+ `MonitoredPipe`. It accepts any stdin redirection value that `Process.spawn`
127
+ supports, such as a filename (`in: 'input.txt'`) or an IO object with a file
128
+ descriptor. Unlike `out:` and `err:`, redirecting stdin from a `StringIO` is not
129
+ supported and will raise `ProcessExecuter::SpawnError`. To feed in-memory data
130
+ to a command's stdin, write it to a `Tempfile` and pass that:
131
+
132
+ ```ruby
133
+ require 'tempfile'
134
+
135
+ Tempfile.create do |file|
136
+ file.write("HEAD\n")
137
+ file.rewind
138
+ result = ProcessExecuter.run('git cat-file --batch-check', in: file)
139
+ end
140
+ ```
141
+
122
142
  ### Encoding
123
143
 
124
144
  #### Encoding summary
data/Rakefile CHANGED
@@ -6,6 +6,9 @@ desc 'Run the same tasks that the CI build will run'
6
6
 
7
7
  if RUBY_PLATFORM == 'java'
8
8
  task default: %w[spec rubocop bundle:audit build]
9
+ elsif RUBY_ENGINE == 'truffleruby'
10
+ # RuboCop raises internal errors when run on TruffleRuby, so skip it there
11
+ task default: %w[spec yard bundle:audit build]
9
12
  else
10
13
  task default: %w[spec rubocop yard bundle:audit build]
11
14
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
4
+
3
5
  require_relative '../errors'
4
6
  require_relative 'spawn_with_timeout'
5
7
 
@@ -19,9 +21,29 @@ module ProcessExecuter
19
21
  # destination. This means that you can redirect to a StringIO which is not possible
20
22
  # with `Process.spawn`.
21
23
  #
24
+ # The wrapper pipes are kept in an internal hash that is combined with the
25
+ # user's options only when `Process.spawn` is called. The options object the
26
+ # caller gave is never modified, so it can be reused for another run and
27
+ # `result.options` returns the destinations as the user configured them.
28
+ #
22
29
  # @api private
23
30
  #
24
31
  class Run < SpawnWithTimeout
32
+ # Create a new Run instance
33
+ #
34
+ # @example
35
+ # options = ProcessExecuter::Options::RunOptions.new(raise_errors: true)
36
+ # result = ProcessExecuter::Commands::Run.new('echo hello', options).call
37
+ # result.success? # => true
38
+ #
39
+ # @param command [Array<String>] The command to run in the subprocess
40
+ # @param options [ProcessExecuter::Options::RunOptions] The options to use when running the command
41
+ #
42
+ def initialize(command, options)
43
+ super
44
+ @redirection_overrides = {}
45
+ end
46
+
25
47
  # Run a command and return the result
26
48
  #
27
49
  # Wrap the stdout and stderr redirection destinations in pipes and then execute
@@ -44,35 +66,130 @@ module ProcessExecuter
44
66
  # @raise [ProcessExecuter::TimeoutError] If the command timed out
45
67
  #
46
68
  # @raise [ProcessExecuter::ProcessIOError] If there was an exception while
47
- # collecting subprocess output
69
+ # collecting subprocess output, or the output was truncated because it
70
+ # could not be fully collected before the close timeout
48
71
  #
49
72
  # @return [ProcessExecuter::Result] The result of the completed subprocess
50
73
  #
51
74
  def call
52
- opened_pipes = wrap_stdout_stderr
75
+ opened_pipes = {}
76
+ wrap_stdout_stderr(opened_pipes)
53
77
  super.tap do
54
78
  log_result
55
79
  raise_errors if options.raise_errors
56
80
  end
57
81
  ensure
58
- opened_pipes.each_value(&:close)
59
- opened_pipes.each { |option_key, pipe| raise_pipe_error(option_key, pipe) }
82
+ close_pipes_and_check_errors(opened_pipes, $ERROR_INFO)
60
83
  end
61
84
 
62
85
  private
63
86
 
64
- # Wrap the stdout and stderr redirection options with a MonitoredPipe
65
- # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
66
- def wrap_stdout_stderr
67
- options.each_with_object({}) do |key_value, opened_pipes|
68
- key, value = key_value
87
+ # Redirection options to apply on top of the user's options at spawn time
88
+ #
89
+ # Holds the {MonitoredPipe} wrappers created by {#wrap_stdout_stderr} (and
90
+ # the capture redirections added by subclasses) keyed by the redirection
91
+ # option key. Keeping them here instead of writing them into {#options}
92
+ # leaves the caller's options object unmodified.
93
+ #
94
+ # @return [Hash<Object, Object>]
95
+ #
96
+ attr_reader :redirection_overrides
69
97
 
98
+ # The options to pass to Process.spawn
99
+ #
100
+ # The user's spawn options with the redirection destinations replaced by
101
+ # their internal {MonitoredPipe} wrappers.
102
+ #
103
+ # @return [Hash]
104
+ #
105
+ def spawn_options = super.merge(redirection_overrides)
106
+
107
+ # Wrap the stdout and stderr redirection options with a MonitoredPipe
108
+ #
109
+ # The wrapper pipes are recorded in {#redirection_overrides} instead of
110
+ # being written into {#options} so the caller's options object is not
111
+ # modified.
112
+ #
113
+ # Each pipe is added to `opened_pipes` as soon as it is created so that,
114
+ # if creating a later pipe raises, the caller's ensure block can close the
115
+ # pipes created so far.
116
+ #
117
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] an
118
+ # accumulator for the opened pipes (the Object is the option key)
119
+ #
120
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] the given `opened_pipes`
121
+ #
122
+ def wrap_stdout_stderr(opened_pipes)
123
+ effective_redirections.each do |key, value|
70
124
  next unless should_wrap?(key, value)
71
125
 
72
126
  wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
73
127
  opened_pipes[key] = wrapped_destination
74
- options.merge!({ key => wrapped_destination })
128
+ redirection_overrides[key] = wrapped_destination
75
129
  end
130
+ opened_pipes
131
+ end
132
+
133
+ # The options as given by the user with {#redirection_overrides} applied
134
+ #
135
+ # @return [Hash<Object, Object>]
136
+ #
137
+ def effective_redirections = options.to_h.merge(redirection_overrides)
138
+
139
+ # Close the given pipes and raise any pipe error unless already unwinding
140
+ #
141
+ # When `in_flight_error` is set, `#call` is unwinding from an exception
142
+ # and that exception (not a pipe destination error or a pipe cleanup
143
+ # error) must be the one the caller sees, so nothing is raised here.
144
+ #
145
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
146
+ #
147
+ # @param in_flight_error [Exception, nil] the exception `#call` is unwinding from, if any
148
+ #
149
+ # @raise [ProcessExecuter::ProcessIOError] if a pipe recorded a destination
150
+ # exception or gave up draining before reaching EOF (truncated output)
151
+ #
152
+ # @raise [StandardError] the first error raised while closing the pipes
153
+ #
154
+ # @return [void]
155
+ #
156
+ def close_pipes_and_check_errors(opened_pipes, in_flight_error)
157
+ close_error = close_pipes(opened_pipes)
158
+ return if in_flight_error
159
+
160
+ opened_pipes.each do |option_key, pipe|
161
+ raise_pipe_error(option_key, pipe)
162
+ raise_truncation_error(option_key, pipe)
163
+ end
164
+ raise close_error if close_error
165
+ end
166
+
167
+ # Close the given pipes, continuing if closing one of them raises
168
+ #
169
+ # Closing continues past a failure so that one pipe's error does not leak
170
+ # the monitoring threads and file descriptors of the pipes after it.
171
+ #
172
+ # All pipes share a single close deadline so that this method -- called
173
+ # from `#call`'s ensure block -- returns in bounded time even when a
174
+ # process outside this object's control (such as an orphaned descendant
175
+ # of a timed out command) still holds a pipe's write fd open. A pipe
176
+ # whose drain is cut short by the deadline records it via
177
+ # {MonitoredPipe#truncated?}.
178
+ #
179
+ # @param opened_pipes [Hash<Object, ProcessExecuter::MonitoredPipe>] the pipes to close
180
+ #
181
+ # @return [StandardError, nil] the first error raised while closing, or nil if none was raised
182
+ #
183
+ def close_pipes(opened_pipes)
184
+ first_close_error = nil
185
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MonitoredPipe::DEFAULT_CLOSE_TIMEOUT
186
+ opened_pipes.each_value do |pipe|
187
+ remaining_time = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ pipe.close(timeout: [remaining_time, 0].max)
189
+ rescue StandardError => e
190
+ first_close_error ||= e
191
+ end
192
+ first_close_error
76
193
  end
77
194
 
78
195
  # Should the redirection option be wrapped by a MonitoredPipe
@@ -119,6 +236,31 @@ module ProcessExecuter
119
236
  error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
120
237
  raise(error, cause: pipe.exception)
121
238
  end
239
+
240
+ # Raises a ProcessIOError if the given pipe's output was truncated
241
+ #
242
+ # Truncation means the pipe gave up draining before reaching EOF
243
+ # ({MonitoredPipe#truncated?}): output the subprocess (or a descendant
244
+ # holding the inherited write fd) produced was discarded instead of
245
+ # being written to the destination. Raising makes that data loss loud
246
+ # rather than letting the command appear to succeed with silently
247
+ # incomplete output.
248
+ #
249
+ # @param option_key [Object] The redirection option key
250
+ #
251
+ # @param pipe [ProcessExecuter::MonitoredPipe] The pipe whose output was truncated
252
+ #
253
+ # @raise [ProcessExecuter::ProcessIOError] If the pipe's output was truncated
254
+ #
255
+ # @return [void]
256
+ #
257
+ def raise_truncation_error(option_key, pipe)
258
+ return unless pipe.truncated?
259
+
260
+ raise ProcessExecuter::ProcessIOError,
261
+ "Output truncated for #{command}: #{option_key.inspect} " \
262
+ 'could not be fully collected before the close timeout'
263
+ end
122
264
  end
123
265
  end
124
266
  end
@@ -50,7 +50,7 @@ module ProcessExecuter
50
50
  @stderr_buffer = StringIO.new
51
51
  stderr_buffer.set_encoding(options.effective_stderr_encoding)
52
52
 
53
- update_capture_options
53
+ add_capture_redirections
54
54
 
55
55
  begin
56
56
  super
@@ -89,15 +89,52 @@ module ProcessExecuter
89
89
  )
90
90
  end
91
91
 
92
- # Updates {options} to include the stdout and stderr capture options
92
+ # Add the stdout and stderr capture redirections to {#redirection_overrides}
93
+ #
94
+ # The capture redirections are not written into {options} so the caller's
95
+ # options object is not modified.
96
+ #
97
+ # When the user gives a combined redirection whose key covers both stdout
98
+ # and stderr (e.g. `[:out, :err] => destination`), a single capture
99
+ # redirection is built for that key so both streams are interleaved into
100
+ # {#stdout_buffer} and {#stderr_buffer} is left empty, mirroring the
101
+ # `merge_output: true` contract.
102
+ #
103
+ # @return [Void]
104
+ #
105
+ def add_capture_redirections
106
+ if options.combined_stdout_and_stderr_redirection?
107
+ add_combined_capture_redirection
108
+ else
109
+ add_stdout_and_stderr_capture_redirections
110
+ end
111
+ end
112
+
113
+ # Add a single capture redirection for a combined stdout/stderr key
114
+ #
115
+ # Both streams are interleaved into {#stdout_buffer}; {#stderr_buffer} is
116
+ # left empty.
117
+ #
118
+ # @return [Void]
119
+ #
120
+ def add_combined_capture_redirection
121
+ redirection_overrides.merge!(
122
+ capture_option(:out, stdout_redirection_source, stdout_redirection_destination, stdout_buffer)
123
+ )
124
+ end
125
+
126
+ # Add separate capture redirections for stdout and stderr
127
+ #
128
+ # If `merge_output: true` was given, stderr is redirected into stdout so
129
+ # both streams are interleaved into {#stdout_buffer}.
93
130
  #
94
131
  # @return [Void]
95
132
  #
96
- def update_capture_options
133
+ def add_stdout_and_stderr_capture_redirections
97
134
  out = stdout_buffer
98
135
  err = options.merge_output ? [:child, 1] : stderr_buffer
99
136
 
100
- options.merge!(
137
+ redirection_overrides.merge!(
101
138
  capture_option(:out, stdout_redirection_source, stdout_redirection_destination, out),
102
139
  capture_option(:err, stderr_redirection_source, stderr_redirection_destination, err)
103
140
  )