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 +4 -4
- data/.editorconfig +10 -0
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +13 -0
- data/.vscode/extensions.json +3 -0
- data/CHANGELOG.md +56 -0
- data/README.md +20 -0
- data/Rakefile +3 -0
- data/lib/process_executer/commands/run.rb +163 -9
- data/lib/process_executer/commands/run_with_capture.rb +89 -35
- data/lib/process_executer/commands/spawn_with_timeout.rb +215 -16
- data/lib/process_executer/destinations/monitored_pipe.rb +7 -4
- data/lib/process_executer/errors.rb +2 -2
- data/lib/process_executer/monitored_pipe.rb +385 -47
- data/lib/process_executer/options/base.rb +48 -7
- data/lib/process_executer/options/run_with_capture_options.rb +92 -61
- data/lib/process_executer/options/spawn_options.rb +13 -0
- data/lib/process_executer/result.rb +13 -2
- data/lib/process_executer/version.rb +1 -1
- data/lib/process_executer.rb +34 -2
- data/process_spawn_test/README.md +38 -1
- data/process_spawn_test/spec/spec_helper.rb +18 -0
- data/process_spawn_test/spec/test_spec.rb +21 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6dbea0ce77694bd73783f7191fc9b32957fefda87f9db6108d97ebf26bb1a9f
|
|
4
|
+
data.tar.gz: e710378f301c56c649063d6bfef0525f0c0c8dcf6adf6329e12184ec24d02894
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 756a9aacc5d173fa2cedd68f178c9e321e141d3ef75f61ddcfe7e3ebe67f236a0603192f24c49aaedbc0dcf209d61dd16b593477f67d974fa1f2e875ec17dd7d
|
|
7
|
+
data.tar.gz: a9ce354984f37839c54abfb453eef726d6467467dc529aaa2108643c2e153005022b097b9a1ecfea368391ff9ebbf8ba20b32b9cd73db11c5076394824d81528
|
data/.editorconfig
ADDED
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"
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
90
|
+
# The stdout and stderr capture redirections
|
|
93
91
|
#
|
|
94
|
-
#
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
115
|
-
#
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
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
|
|
128
|
-
def
|
|
129
|
-
if
|
|
130
|
-
|
|
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
|
-
{
|
|
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
|