process_executer 4.0.4 → 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 +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 +42 -0
- data/README.md +20 -0
- data/Rakefile +3 -0
- data/lib/process_executer/commands/run.rb +152 -10
- data/lib/process_executer/commands/run_with_capture.rb +41 -4
- data/lib/process_executer/commands/spawn_with_timeout.rb +185 -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 +373 -44
- data/lib/process_executer/options/base.rb +32 -3
- data/lib/process_executer/options/run_with_capture_options.rb +70 -8
- 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
- data/release-please-config.json +1 -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: 63acfcf3541790f98d140feac5a9badc901f8b4e475b3c97d125f6b549556bfc
|
|
4
|
+
data.tar.gz: 7c42a5cfa8b66fea530be0821443a974c1fabc1002fc2b3f2f35e533812c9c6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8d54aa2b9c5e1c153a4c92dbaeec6d96c3b80f31f6517ad2026519d070eff6d6c5b6fdc447cfbff481a6c15599c06ace734068f784e1f1ca8ba78831e84e0d7
|
|
7
|
+
data.tar.gz: fef70d1e6342139152e498dd72572f591f5f330f8617e7ad0603898638c08db8e2ec09aeb302e486949432d80a7635611654959869314946df13c1bf87ed4dd0
|
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,48 @@ 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
|
+
|
|
8
50
|
## [4.0.4](https://github.com/main-branch/process_executer/compare/v4.0.3...v4.0.4) (2026-04-24)
|
|
9
51
|
|
|
10
52
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
)
|
|
@@ -48,7 +48,7 @@ module ProcessExecuter
|
|
|
48
48
|
#
|
|
49
49
|
def call
|
|
50
50
|
begin
|
|
51
|
-
@pid = Process.spawn(*command, **
|
|
51
|
+
@pid = Process.spawn(*command, **spawn_options)
|
|
52
52
|
rescue StandardError => e
|
|
53
53
|
raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}"
|
|
54
54
|
end
|
|
@@ -80,10 +80,14 @@ module ProcessExecuter
|
|
|
80
80
|
|
|
81
81
|
# The status returned by Process.wait2
|
|
82
82
|
#
|
|
83
|
+
# nil when the timeout was delivered after the wait had already reaped
|
|
84
|
+
# the subprocess: the status was lost to the raise and {#timed_out?} is
|
|
85
|
+
# true.
|
|
86
|
+
#
|
|
83
87
|
# @example
|
|
84
88
|
# spawn.status #=> #<Process::Status: pid 12345 exit 0>
|
|
85
89
|
#
|
|
86
|
-
# @return [Process::Status]
|
|
90
|
+
# @return [Process::Status, nil]
|
|
87
91
|
#
|
|
88
92
|
attr_reader :status
|
|
89
93
|
|
|
@@ -118,6 +122,47 @@ module ProcessExecuter
|
|
|
118
122
|
|
|
119
123
|
private
|
|
120
124
|
|
|
125
|
+
# The options to pass to Process.spawn
|
|
126
|
+
#
|
|
127
|
+
# Subclasses may override this method to combine internal redirections
|
|
128
|
+
# with the user's options without modifying the options object the
|
|
129
|
+
# caller gave.
|
|
130
|
+
#
|
|
131
|
+
# @return [Hash]
|
|
132
|
+
#
|
|
133
|
+
def spawn_options = options.spawn_options.merge(process_group_options)
|
|
134
|
+
|
|
135
|
+
# Spawn options that place the subprocess into its own process group
|
|
136
|
+
#
|
|
137
|
+
# When `timeout_after` is set to a value that can fire (`nil` and `0`
|
|
138
|
+
# mean "no timeout"), the subprocess is made the leader of a new process
|
|
139
|
+
# group so that a timeout can kill the whole group -- including
|
|
140
|
+
# descendants that inherited the redirections -- instead of just the
|
|
141
|
+
# direct child. Empty when no timeout can fire or when the caller gave a
|
|
142
|
+
# `pgroup`/`new_pgroup` option themselves (their setting is honored).
|
|
143
|
+
#
|
|
144
|
+
# A new process group is a background group for any terminal the
|
|
145
|
+
# subprocess inherits, so an interactive subprocess that reads the
|
|
146
|
+
# terminal is stopped by `SIGTTIN` and then killed when the timeout
|
|
147
|
+
# fires -- which is the bound `timeout_after` promises. A caller who
|
|
148
|
+
# needs an interactive subprocess to stay in the foreground process
|
|
149
|
+
# group can pass their own `pgroup` option.
|
|
150
|
+
#
|
|
151
|
+
# @return [Hash]
|
|
152
|
+
#
|
|
153
|
+
def process_group_options
|
|
154
|
+
return {} unless options.timeout_after&.positive?
|
|
155
|
+
return {} unless options.pgroup == :not_set && options.new_pgroup == :not_set
|
|
156
|
+
|
|
157
|
+
windows? ? { new_pgroup: true } : { pgroup: true }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Whether the current platform is Windows
|
|
161
|
+
#
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
#
|
|
164
|
+
def windows? = Gem.win_platform?
|
|
165
|
+
|
|
121
166
|
# Wait for process to terminate
|
|
122
167
|
#
|
|
123
168
|
# If a `:timeout_after` is specified in options, terminate the process after the
|
|
@@ -142,21 +187,145 @@ module ProcessExecuter
|
|
|
142
187
|
|
|
143
188
|
# Wait for a process to terminate returning the status and timed out flag
|
|
144
189
|
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
190
|
+
# An exception other than the timeout (an `Interrupt` from Ctrl-C, for
|
|
191
|
+
# example) abandons the wait; {#kill_and_reap_abandoned_subprocess} then
|
|
192
|
+
# cleans up a subprocess this class isolated into its own process group
|
|
193
|
+
# before the exception propagates.
|
|
194
|
+
#
|
|
195
|
+
# @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
|
|
196
|
+
# the process status (nil when the timeout raced the wait and the status was lost,
|
|
197
|
+
# see {#wait_with_timeout}) and a boolean indicating whether the process timed out
|
|
147
198
|
def wait_for_process_raw
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
wait_with_timeout
|
|
200
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
201
|
+
kill_and_reap_abandoned_subprocess
|
|
202
|
+
raise
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Wait for the process, killing it when `timeout_after` expires first
|
|
206
|
+
#
|
|
207
|
+
# The timeout can be delivered after the timed wait has already reaped
|
|
208
|
+
# the subprocess but before it returns. In that race the subprocess's
|
|
209
|
+
# status was lost to the raise, so the status is nil and the timed out
|
|
210
|
+
# flag is still set.
|
|
211
|
+
#
|
|
212
|
+
# @return [Array(Process::Status, Boolean), Array(nil, Boolean)] an array containing
|
|
213
|
+
# the process status (nil when the timeout raced the wait and the status was lost)
|
|
214
|
+
# and a boolean indicating whether the process timed out
|
|
215
|
+
def wait_with_timeout
|
|
216
|
+
process_status = Timeout.timeout(options.timeout_after) { Process.wait2(pid).last }
|
|
217
|
+
[process_status, false]
|
|
218
|
+
rescue Timeout::Error
|
|
219
|
+
kill_subprocess
|
|
220
|
+
begin
|
|
221
|
+
[Process.wait2(pid).last, true]
|
|
222
|
+
rescue Errno::ECHILD
|
|
223
|
+
# the interrupted wait already reaped the subprocess; its status was
|
|
224
|
+
# lost to the raise
|
|
225
|
+
[nil, true]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Kill and reap the subprocess when its wait was abandoned by an exception
|
|
230
|
+
#
|
|
231
|
+
# Only applies to a subprocess this class isolated into its own process
|
|
232
|
+
# group: such a subprocess no longer receives terminal-generated signals
|
|
233
|
+
# (Ctrl-C sends `SIGINT` to the caller's foreground group, not to the
|
|
234
|
+
# new group), so an exception that abandons the wait would otherwise
|
|
235
|
+
# leave it and its descendants running unsupervised and unreaped. A
|
|
236
|
+
# subprocess whose process group came from the caller's own options
|
|
237
|
+
# keeps its pre-existing signal semantics and is left alone.
|
|
238
|
+
#
|
|
239
|
+
# Rescues `Exception` (not just `StandardError`) so that a second async
|
|
240
|
+
# exception delivered during this best-effort cleanup cannot replace
|
|
241
|
+
# the exception already being re-raised by the caller.
|
|
242
|
+
#
|
|
243
|
+
# @return [void]
|
|
244
|
+
#
|
|
245
|
+
def kill_and_reap_abandoned_subprocess
|
|
246
|
+
return unless isolated_in_new_process_group?
|
|
247
|
+
|
|
248
|
+
kill_subprocess
|
|
249
|
+
Process.wait2(pid)
|
|
250
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
251
|
+
# the subprocess may already be dead and reaped; the wait's exception
|
|
252
|
+
# is what must propagate
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Forcibly terminate the timed out subprocess and (if possible) its descendants
|
|
256
|
+
#
|
|
257
|
+
# When the subprocess was spawned as the leader of its own process
|
|
258
|
+
# group, the whole group is killed so that descendants that would
|
|
259
|
+
# otherwise survive the timeout (and keep any inherited redirection
|
|
260
|
+
# file descriptors open) are terminated too, falling back to killing
|
|
261
|
+
# the direct child if the group kill fails. A group signal reaches only
|
|
262
|
+
# the processes still in that group: a descendant that started its own
|
|
263
|
+
# session or joined another process group (a daemon, for example) is
|
|
264
|
+
# not killed. Otherwise the subprocess is in a process group this
|
|
265
|
+
# object did not create, so only the direct child is killed, matching
|
|
266
|
+
# the pre-process-group behavior.
|
|
267
|
+
#
|
|
268
|
+
# Killing a process group is only possible on POSIX platforms. On
|
|
269
|
+
# Windows, `Process.kill` cannot signal a process group (a negative pid
|
|
270
|
+
# raises an error), so the group kill always falls back to the direct
|
|
271
|
+
# child and descendants may survive the timeout; the bounded
|
|
272
|
+
# {MonitoredPipe#close} keeps such descendants from blocking
|
|
273
|
+
# {ProcessExecuter.run} indefinitely.
|
|
274
|
+
#
|
|
275
|
+
# A subprocess that already exited and was reaped before the signal is
|
|
276
|
+
# sent (the timeout racing the wait) leaves nothing to kill; that is not
|
|
277
|
+
# an error. In that same microsecond window the freed pid could in
|
|
278
|
+
# principle be recycled to an unrelated process, a hazard inherent to
|
|
279
|
+
# signaling by pid: Ruby exposes no race-free process handle (such as
|
|
280
|
+
# Linux's pidfd) that would eliminate it, and reuse would require the OS
|
|
281
|
+
# to cycle through its entire pid space within the window.
|
|
282
|
+
#
|
|
283
|
+
# @return [void]
|
|
284
|
+
#
|
|
285
|
+
def kill_subprocess
|
|
286
|
+
return if process_group_leader? && kill_process_group
|
|
287
|
+
|
|
288
|
+
Process.kill('KILL', pid)
|
|
289
|
+
rescue Errno::ESRCH
|
|
290
|
+
# the subprocess already exited and was reaped between the interrupted
|
|
291
|
+
# wait and the kill; there is nothing left to kill
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Whether the spawn options made the subprocess a new process group leader
|
|
295
|
+
#
|
|
296
|
+
# True when the process group option -- added by {#process_group_options}
|
|
297
|
+
# or given by the caller -- asks for a new process group with the
|
|
298
|
+
# subprocess as its leader (`pgroup: true`, `pgroup: 0`, or
|
|
299
|
+
# `new_pgroup: true`). False when there is no process group option or
|
|
300
|
+
# when `pgroup` places the subprocess in an existing process group.
|
|
301
|
+
#
|
|
302
|
+
# @return [Boolean]
|
|
303
|
+
#
|
|
304
|
+
def process_group_leader?
|
|
305
|
+
[true, 0].include?(spawn_options[:pgroup]) || spawn_options[:new_pgroup] == true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Whether this class isolated the subprocess into its own process group
|
|
309
|
+
#
|
|
310
|
+
# True when the subprocess is a new process group leader and that came
|
|
311
|
+
# from {#process_group_options} rather than from a `pgroup`/`new_pgroup`
|
|
312
|
+
# option the caller supplied.
|
|
313
|
+
#
|
|
314
|
+
# @return [Boolean]
|
|
315
|
+
#
|
|
316
|
+
def isolated_in_new_process_group?
|
|
317
|
+
process_group_leader? && options.pgroup == :not_set && options.new_pgroup == :not_set
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Send SIGKILL to the subprocess's process group
|
|
321
|
+
#
|
|
322
|
+
# @return [Boolean] true if the signal was sent, false if doing so raised an error
|
|
323
|
+
#
|
|
324
|
+
def kill_process_group
|
|
325
|
+
Process.kill('KILL', -pid)
|
|
326
|
+
true
|
|
327
|
+
rescue StandardError
|
|
328
|
+
false
|
|
160
329
|
end
|
|
161
330
|
end
|
|
162
331
|
end
|