process_executer 4.0.4 → 4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 326f0746501479260d59d78579f9ff9e049a516a7b76e177e7ade04b90f0eece
4
- data.tar.gz: 7352423248b6d8d7b1785e450ff0aae1c836fa1dfcc2a4e6b791d29d8a3d386c
3
+ metadata.gz: a6dbea0ce77694bd73783f7191fc9b32957fefda87f9db6108d97ebf26bb1a9f
4
+ data.tar.gz: e710378f301c56c649063d6bfef0525f0c0c8dcf6adf6329e12184ec24d02894
5
5
  SHA512:
6
- metadata.gz: 2147f37dc71f48a5c8a525103bc517d4be9bf4fde29f1a7d4d12952cf42976c96567256c5eea8ffe101d409829f63011093828551bc30fc779627978df7219c7
7
- data.tar.gz: 175fe9b38d29b3b6c7441293ca837344231019f22d0cb815013cd766f606487873ebb062134e00a493f3a5c41a7f1f5b6c2d3e152ea51ab800f12536aa695d50
6
+ metadata.gz: 756a9aacc5d173fa2cedd68f178c9e321e141d3ef75f61ddcfe7e3ebe67f236a0603192f24c49aaedbc0dcf209d61dd16b593477f67d974fa1f2e875ec17dd7d
7
+ data.tar.gz: a9ce354984f37839c54abfb453eef726d6467467dc529aaa2108643c2e153005022b097b9a1ecfea368391ff9ebbf8ba20b32b9cd73db11c5076394824d81528
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.4"
2
+ ".": "4.1.1"
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,62 @@ 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.1](https://github.com/main-branch/process_executer/compare/v4.1.0...v4.1.1) (2026-08-31)
9
+
10
+
11
+ ### Other Changes
12
+
13
+ * Characterize validation ordering when unknown and invalid options are given together ([#192](https://github.com/main-branch/process_executer/issues/192)) ([15d6e06](https://github.com/main-branch/process_executer/commit/15d6e063170484f309efd30867238b867484288b))
14
+ * Collapse encoding validators and clarify capture-redirection assembly ([#194](https://github.com/main-branch/process_executer/issues/194)) ([bab8610](https://github.com/main-branch/process_executer/commit/bab86101ac4cdbd3747e72d0d7f841623a6e9b20))
15
+ * Contribute subclass redirections via an internal_redirections hook ([#190](https://github.com/main-branch/process_executer/issues/190)) ([8ecbda5](https://github.com/main-branch/process_executer/commit/8ecbda54874de933ab07a64d91e0a07e17f7ece0))
16
+ * Derive the process-group isolation decision from a single source ([#191](https://github.com/main-branch/process_executer/issues/191)) ([5d8ac7d](https://github.com/main-branch/process_executer/commit/5d8ac7dc38a369a7fe6d6cf3d05bd1eec799f3dc))
17
+ * Describe every source of MonitoredPipe#exception ([#193](https://github.com/main-branch/process_executer/issues/193)) ([333b7bd](https://github.com/main-branch/process_executer/commit/333b7bd4c552c246d7d7505b5aa3e1a123ac5f9b))
18
+ * Extract shared validate! from Options::Base#initialize and #merge! ([#192](https://github.com/main-branch/process_executer/issues/192)) ([422956a](https://github.com/main-branch/process_executer/commit/422956acaab46e29ecc1373d017d719ea6eb497f))
19
+ * Remove the release-as 4.1.0 override from the release-please config ([65de5b8](https://github.com/main-branch/process_executer/commit/65de5b8b4a7f926c4e71b094ead5ff9abc6ff102))
20
+ * Track opened pipes as per-call instance state instead of an out-parameter ([#190](https://github.com/main-branch/process_executer/issues/190)) ([f279f1e](https://github.com/main-branch/process_executer/commit/f279f1e0672b91b966ee73cbadf00749522e488d))
21
+
22
+ ## [4.1.0](https://github.com/main-branch/process_executer/compare/v4.0.4...v4.1.0) (2026-08-30)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * Make timeout_after bound execution even when descendants hold stdout/stderr open ([a1298d3](https://github.com/main-branch/process_executer/commit/a1298d3f98fd8e57f58f50a0566dd3be78079189))
28
+ * **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)
29
+ * **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)
30
+ * **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)
31
+ * Replace the monitor thread's 1ms poll with an event-driven wakeup pipe ([44068a3](https://github.com/main-branch/process_executer/commit/44068a35f4f87de9b7b46ce1deffda636f7c1138))
32
+ * 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)
33
+ * **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)
34
+ * **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)
35
+ * **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)
36
+ * 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))
37
+
38
+
39
+ ### Other Changes
40
+
41
+ * 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))
42
+ * 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))
43
+ * 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))
44
+ * 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))
45
+ * **coverage:** Only enforce code coverage on MRI builds not on Windows ([160815e](https://github.com/main-branch/process_executer/commit/160815ecb091d1f155406f9a1b37f73e13690627))
46
+ * Enforce LF line endings on all platforms ([e94bfea](https://github.com/main-branch/process_executer/commit/e94bfeaf4bd93506090277b4020859cd23dfc625))
47
+ * 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)
48
+ * **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)
49
+ * **monitored_pipe:** Add a failing test for the #write deadlock ([9826558](https://github.com/main-branch/process_executer/commit/9826558035b03fe7becdc729053519141ba8d00d))
50
+ * **monitored_pipe:** Add failing tests for a destination raising a non-StandardError ([0177329](https://github.com/main-branch/process_executer/commit/0177329fb47f3c185d9ea768f9f80028c54fb7ab))
51
+ * **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)
52
+ * **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)
53
+ * **monitored_pipe:** Stub the monitor loop on the class, not the live instance ([799177f](https://github.com/main-branch/process_executer/commit/799177f6046d661013ce6b68b19c6b220294d00f))
54
+ * **process-spawn:** Check exit status and blocking, and add a subspawn backend ([99021d2](https://github.com/main-branch/process_executer/commit/99021d2144a21210f793b7ae94d36875eef504e6))
55
+ * **process-spawn:** Run the spawn tests against the subspawn backend ([573086f](https://github.com/main-branch/process_executer/commit/573086f194e790f25d916de32bb5e94437a5ae86))
56
+ * **rake:** Skip RuboCop in the default task on TruffleRuby ([3d2a118](https://github.com/main-branch/process_executer/commit/3d2a1186d65e76f170f5428d611822d95d8fdc2d))
57
+ * Release the hardening changes as v4.1.0 instead of v4.0.5 ([70b6327](https://github.com/main-branch/process_executer/commit/70b63278f334bd8b714c2fc28ded1520921add09))
58
+ * **run_with_capture:** Add failing tests for combined [:out, :err] redirection ([61e97cb](https://github.com/main-branch/process_executer/commit/61e97cb09c816cbe30d81dc33f268accf76ab396))
59
+ * **run:** Add failing tests for options mutation and caller-owned pipe closing ([cf32615](https://github.com/main-branch/process_executer/commit/cf326156381fe81562597906d43134a08ebbef17))
60
+ * **run:** Add failing tests for pipe creation failure in Run#call ([21521d9](https://github.com/main-branch/process_executer/commit/21521d9a9b5141d9bb6d22fb5fd3e7de0e497ca2))
61
+ * **run:** Update the JRuby expectation for an invalid chdir path ([2712f0b](https://github.com/main-branch/process_executer/commit/2712f0bdf060fa1831edb428183283dec5f2727a))
62
+ * Use RbConfig.ruby to locate the Ruby interpreter ([82e65e2](https://github.com/main-branch/process_executer/commit/82e65e202a4f884a3283b4af00fcd2b5eda3a394))
63
+
8
64
  ## [4.0.4](https://github.com/main-branch/process_executer/compare/v4.0.3...v4.0.4) (2026-04-24)
9
65
 
10
66
 
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,6 +21,13 @@ 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 internal per-call state that is combined
25
+ # with the user's options only when `Process.spawn` is called. Subclasses
26
+ # contribute additional redirections by overriding `#internal_redirections`.
27
+ # The options object the caller gave is never modified, so it can be reused
28
+ # for another run and `result.options` returns the destinations as the user
29
+ # configured them.
30
+ #
22
31
  # @api private
23
32
  #
24
33
  class Run < SpawnWithTimeout
@@ -44,37 +53,157 @@ module ProcessExecuter
44
53
  # @raise [ProcessExecuter::TimeoutError] If the command timed out
45
54
  #
46
55
  # @raise [ProcessExecuter::ProcessIOError] If there was an exception while
47
- # collecting subprocess output
56
+ # collecting subprocess output, or the output was truncated because it
57
+ # could not be fully collected before the close timeout
48
58
  #
49
59
  # @return [ProcessExecuter::Result] The result of the completed subprocess
50
60
  #
51
61
  def call
52
- opened_pipes = wrap_stdout_stderr
62
+ @opened_pipes = {}
63
+ @redirection_overrides = internal_redirections
64
+ wrap_stdout_stderr
53
65
  super.tap do
54
66
  log_result
55
67
  raise_errors if options.raise_errors
56
68
  end
57
69
  ensure
58
- opened_pipes.each_value(&:close)
59
- opened_pipes.each { |option_key, pipe| raise_pipe_error(option_key, pipe) }
70
+ close_pipes_and_check_errors($ERROR_INFO)
60
71
  end
61
72
 
62
73
  private
63
74
 
75
+ # Redirection options to apply on top of the user's options at spawn time
76
+ #
77
+ # Reset at the start of each {#call}: seeded with
78
+ # {#internal_redirections} and then updated by {#wrap_stdout_stderr},
79
+ # which replaces each eligible destination with its {MonitoredPipe}
80
+ # wrapper. Keeping these here instead of writing them into {#options}
81
+ # leaves the caller's options object unmodified.
82
+ #
83
+ # Unlike {#opened_pipes}, this hash may hold values that are not
84
+ # {MonitoredPipe}s (for example, the `[:child, 1]` redirection a subclass
85
+ # adds for merged output).
86
+ #
87
+ # @return [Hash<Object, Object>]
88
+ #
89
+ attr_reader :redirection_overrides
90
+
91
+ # Redirections this class adds on top of the user's options
92
+ #
93
+ # Called once at the start of each {#call} to seed
94
+ # {#redirection_overrides}, before {#wrap_stdout_stderr} runs. Returns an
95
+ # empty hash; subclasses override this method to contribute their own
96
+ # redirections (such as {RunWithCapture}'s capture redirections) instead
97
+ # of mutating this object's state.
98
+ #
99
+ # @return [Hash<Object, Object>]
100
+ #
101
+ def internal_redirections = {}
102
+
103
+ # The wrapper pipes created by {#wrap_stdout_stderr}
104
+ #
105
+ # Keyed by the redirection option key. Reset at the start of each {#call}. By construction this hash contains
106
+ # exactly the pipes this object created during the current call -- never
107
+ # a destination supplied by the caller -- so `#call`'s ensure block can
108
+ # close everything in it without ever closing a caller-owned pipe (a
109
+ # caller's own {MonitoredPipe} destination is wrapped like any other
110
+ # destination, and only the wrapper is recorded here).
111
+ #
112
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>]
113
+ #
114
+ attr_reader :opened_pipes
115
+
116
+ # The options to pass to Process.spawn
117
+ #
118
+ # The user's spawn options with the redirection destinations replaced by
119
+ # their internal {MonitoredPipe} wrappers.
120
+ #
121
+ # @return [Hash]
122
+ #
123
+ def spawn_options = super.merge(redirection_overrides)
124
+
64
125
  # 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)
126
+ #
127
+ # Each wrapper pipe is recorded in two collections with different roles:
128
+ # {#opened_pipes}, the pipes this object owns and must close, and
129
+ # {#redirection_overrides}, the destinations to hand `Process.spawn`.
130
+ # Neither is written into {#options}, so the caller's options object is
131
+ # not modified.
132
+ #
133
+ # Each pipe is recorded as soon as it is created so that, if creating a
134
+ # later pipe raises, `#call`'s ensure block can close the pipes created
135
+ # so far.
136
+ #
137
+ # @return [void]
138
+ #
66
139
  def wrap_stdout_stderr
67
- options.each_with_object({}) do |key_value, opened_pipes|
68
- key, value = key_value
69
-
140
+ effective_redirections.each do |key, value|
70
141
  next unless should_wrap?(key, value)
71
142
 
72
143
  wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
73
144
  opened_pipes[key] = wrapped_destination
74
- options.merge!({ key => wrapped_destination })
145
+ redirection_overrides[key] = wrapped_destination
75
146
  end
76
147
  end
77
148
 
149
+ # The options as given by the user with {#redirection_overrides} applied
150
+ #
151
+ # @return [Hash<Object, Object>]
152
+ #
153
+ def effective_redirections = options.to_h.merge(redirection_overrides)
154
+
155
+ # Close the opened pipes and raise any pipe error unless already unwinding
156
+ #
157
+ # When `in_flight_error` is set, `#call` is unwinding from an exception
158
+ # and that exception (not a pipe destination error or a pipe cleanup
159
+ # error) must be the one the caller sees, so nothing is raised here.
160
+ #
161
+ # @param in_flight_error [Exception, nil] the exception `#call` is unwinding from, if any
162
+ #
163
+ # @raise [ProcessExecuter::ProcessIOError] if a pipe recorded a destination
164
+ # exception or gave up draining before reaching EOF (truncated output)
165
+ #
166
+ # @raise [StandardError] the first error raised while closing the pipes
167
+ #
168
+ # @return [void]
169
+ #
170
+ def close_pipes_and_check_errors(in_flight_error)
171
+ close_error = close_pipes
172
+ return if in_flight_error
173
+
174
+ opened_pipes.each do |option_key, pipe|
175
+ raise_pipe_error(option_key, pipe)
176
+ raise_truncation_error(option_key, pipe)
177
+ end
178
+ raise close_error if close_error
179
+ end
180
+
181
+ # Close the opened pipes, continuing if closing one of them raises
182
+ #
183
+ # Closing continues past a failure so that one pipe's error does not leak
184
+ # the monitoring threads and file descriptors of the pipes after it.
185
+ #
186
+ # All pipes share a single close deadline so that this method -- called
187
+ # from `#call`'s ensure block -- returns in bounded time even when a
188
+ # process outside this object's control (such as an orphaned descendant
189
+ # of a timed out command) still holds a pipe's write fd open. A pipe
190
+ # whose drain is cut short by the deadline records it via
191
+ # {MonitoredPipe#truncated?}.
192
+ #
193
+ # @return [StandardError, nil] the first error raised while closing, or nil if none was raised
194
+ #
195
+ def close_pipes
196
+ first_close_error = nil
197
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MonitoredPipe::DEFAULT_CLOSE_TIMEOUT
198
+ opened_pipes.each_value do |pipe|
199
+ remaining_time = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
200
+ pipe.close(timeout: [remaining_time, 0].max)
201
+ rescue StandardError => e
202
+ first_close_error ||= e
203
+ end
204
+ first_close_error
205
+ end
206
+
78
207
  # Should the redirection option be wrapped by a MonitoredPipe
79
208
  # @param key [Object] The option key
80
209
  # @param value [Object] The option value
@@ -119,6 +248,31 @@ module ProcessExecuter
119
248
  error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
120
249
  raise(error, cause: pipe.exception)
121
250
  end
251
+
252
+ # Raises a ProcessIOError if the given pipe's output was truncated
253
+ #
254
+ # Truncation means the pipe gave up draining before reaching EOF
255
+ # ({MonitoredPipe#truncated?}): output the subprocess (or a descendant
256
+ # holding the inherited write fd) produced was discarded instead of
257
+ # being written to the destination. Raising makes that data loss loud
258
+ # rather than letting the command appear to succeed with silently
259
+ # incomplete output.
260
+ #
261
+ # @param option_key [Object] The redirection option key
262
+ #
263
+ # @param pipe [ProcessExecuter::MonitoredPipe] The pipe whose output was truncated
264
+ #
265
+ # @raise [ProcessExecuter::ProcessIOError] If the pipe's output was truncated
266
+ #
267
+ # @return [void]
268
+ #
269
+ def raise_truncation_error(option_key, pipe)
270
+ return unless pipe.truncated?
271
+
272
+ raise ProcessExecuter::ProcessIOError,
273
+ "Output truncated for #{command}: #{option_key.inspect} " \
274
+ 'could not be fully collected before the close timeout'
275
+ end
122
276
  end
123
277
  end
124
278
  end
@@ -50,8 +50,6 @@ module ProcessExecuter
50
50
  @stderr_buffer = StringIO.new
51
51
  stderr_buffer.set_encoding(options.effective_stderr_encoding)
52
52
 
53
- update_capture_options
54
-
55
53
  begin
56
54
  super
57
55
  ensure
@@ -89,54 +87,110 @@ module ProcessExecuter
89
87
  )
90
88
  end
91
89
 
92
- # Updates {options} to include the stdout and stderr capture options
90
+ # The stdout and stderr capture redirections
93
91
  #
94
- # @return [Void]
92
+ # Called by {Run#call} to seed the redirection overrides, so the capture
93
+ # redirections are layered on top of the user's options at spawn time
94
+ # without writing into {#options} or mutating the parent's state.
95
+ #
96
+ # When the user gives a combined redirection whose key covers both stdout
97
+ # and stderr (e.g. `[:out, :err] => destination`), a single capture
98
+ # redirection is built for that key so both streams are interleaved into
99
+ # {#stdout_buffer} and {#stderr_buffer} is left empty, mirroring the
100
+ # `merge_output: true` contract.
101
+ #
102
+ # @return [Hash<Object, Object>]
95
103
  #
96
- def update_capture_options
97
- out = stdout_buffer
98
- err = options.merge_output ? [:child, 1] : stderr_buffer
104
+ def internal_redirections
105
+ if options.combined_stdout_and_stderr_redirection?
106
+ combined_capture_redirection
107
+ else
108
+ stdout_and_stderr_capture_redirections
109
+ end
110
+ end
99
111
 
100
- options.merge!(
101
- capture_option(:out, stdout_redirection_source, stdout_redirection_destination, out),
102
- capture_option(:err, stderr_redirection_source, stderr_redirection_destination, err)
112
+ # A single capture redirection for a combined stdout/stderr key
113
+ #
114
+ # Both streams are interleaved into {#stdout_buffer}; {#stderr_buffer} is
115
+ # left empty.
116
+ #
117
+ # @return [Hash<Object, Object>]
118
+ #
119
+ def combined_capture_redirection
120
+ tee_capture_redirection(
121
+ options.stdout_redirection_source, options.stdout_redirection_destination, stdout_buffer
103
122
  )
104
123
  end
105
124
 
106
- # The source for stdout redirection
107
- # @return [Object]
108
- def stdout_redirection_source = options.stdout_redirection_source
125
+ # Separate capture redirections for stdout and stderr
126
+ #
127
+ # If `merge_output: true` was given, stderr is redirected into stdout so
128
+ # both streams are interleaved into {#stdout_buffer}.
129
+ #
130
+ # @return [Hash<Object, Object>]
131
+ #
132
+ def stdout_and_stderr_capture_redirections
133
+ stdout_capture_redirection.merge(stderr_capture_redirection)
134
+ end
135
+
136
+ # The redirection that captures stdout into {#stdout_buffer}
137
+ #
138
+ # Tees the buffer onto the user's stdout redirection if one was given;
139
+ # otherwise installs the plain default capture redirection.
140
+ #
141
+ # @return [Hash<Object, Object>]
142
+ #
143
+ def stdout_capture_redirection
144
+ source = options.stdout_redirection_source
145
+ return default_capture_redirection(:out, stdout_buffer) unless source
109
146
 
110
- # The source for stderr redirection
111
- # @return [Object]
112
- def stderr_redirection_source = options.stderr_redirection_source
147
+ tee_capture_redirection(source, options.stdout_redirection_destination, stdout_buffer)
148
+ end
113
149
 
114
- # The destination for stdout redirection
115
- # @return [Object]
116
- def stdout_redirection_destination = options.stdout_redirection_destination
150
+ # The redirection that captures stderr
151
+ #
152
+ # Stderr is captured into {#stderr_buffer}, or into stdout (and thereby
153
+ # {#stdout_buffer}) if `merge_output: true` was given. Tees the capture
154
+ # destination onto the user's stderr redirection if one was given;
155
+ # otherwise installs the plain default capture redirection.
156
+ #
157
+ # @return [Hash<Object, Object>]
158
+ #
159
+ def stderr_capture_redirection
160
+ capture_destination = options.merge_output ? [:child, 1] : stderr_buffer
161
+ source = options.stderr_redirection_source
162
+ return default_capture_redirection(:err, capture_destination) unless source
117
163
 
118
- # The destination for stderr redirection
119
- # @return [Object]
120
- def stderr_redirection_destination = options.stderr_redirection_destination
164
+ tee_capture_redirection(source, options.stderr_redirection_destination, capture_destination)
165
+ end
121
166
 
122
- # Add the capture redirection to existing options (if any)
123
- # @param redirection_source [Symbol, Integer] The source of the redirection (e.g., :out, :err)
124
- # @param given_source [Symbol, Integer, nil] The source provided by the user (if any)
125
- # @param given_destination [Object, nil] The destination provided by the user (if any)
167
+ # Tee the capture destination onto the redirection the user gave
168
+ #
169
+ # If the user's destination is already a tee, the capture destination is
170
+ # added to it; otherwise the user's destination and the capture
171
+ # destination are wrapped in a new tee.
172
+ #
173
+ # @param source [Symbol, Integer, Array] The redirection source the user gave
174
+ # @param destination [Object] The redirection destination the user gave
126
175
  # @param capture_destination [Object] The additional destination to capture output to
127
- # @return [Hash] The option (including the capture_destination) to merge into options
128
- def capture_option(redirection_source, given_source, given_destination, capture_destination)
129
- if given_source
130
- if Destinations::Tee.handles?(given_destination)
131
- { given_source => given_destination + [capture_destination] }
132
- else
133
- { given_source => [:tee, given_destination, capture_destination] }
134
- end
176
+ # @return [Hash] The redirection to merge into options
177
+ def tee_capture_redirection(source, destination, capture_destination)
178
+ if Destinations::Tee.handles?(destination)
179
+ { source => destination + [capture_destination] }
135
180
  else
136
- { redirection_source => capture_destination }
181
+ { source => [:tee, destination, capture_destination] }
137
182
  end
138
183
  end
139
184
 
185
+ # The plain capture redirection used when the user gave no redirection
186
+ #
187
+ # @param source [Symbol] The redirection source (:out or :err)
188
+ # @param capture_destination [Object] The destination to capture output to
189
+ # @return [Hash] The redirection to merge into options
190
+ def default_capture_redirection(source, capture_destination)
191
+ { source => capture_destination }
192
+ end
193
+
140
194
  # Log the captured command output to the given logger at debug level
141
195
  # @return [Void]
142
196
  def log_command_output