process_executer 4.0.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,12 +23,16 @@ module ProcessExecuter
23
23
  # `encoding`, and finally defaults to `DEFAULT_ENCODING` if neither
24
24
  # is available.
25
25
  #
26
- # @return [Encoding]
26
+ # The value is canonicalized to an Encoding object, so equivalent
27
+ # representations (e.g. `Encoding::UTF_8`, `'UTF-8'`, or `:binary` for
28
+ # `Encoding::BINARY`) all resolve to the same Encoding.
29
+ #
30
+ # @return [Encoding, nil] nil if the value names an unknown encoding
27
31
  #
28
32
  # @api private
29
33
  #
30
34
  def effective_stdout_encoding
31
- stdout_encoding || encoding || DEFAULT_ENCODING
35
+ canonical_encoding(stdout_encoding || encoding || DEFAULT_ENCODING)
32
36
  end
33
37
 
34
38
  # Determines the character encoding to use for stderr
@@ -37,12 +41,16 @@ module ProcessExecuter
37
41
  # `encoding`, and finally defaults to `DEFAULT_ENCODING` if neither
38
42
  # is available.
39
43
  #
40
- # @return [Encoding]
44
+ # The value is canonicalized to an Encoding object, so equivalent
45
+ # representations (e.g. `Encoding::UTF_8`, `'UTF-8'`, or `:binary` for
46
+ # `Encoding::BINARY`) all resolve to the same Encoding.
47
+ #
48
+ # @return [Encoding, nil] nil if the value names an unknown encoding
41
49
  #
42
50
  # @api private
43
51
  #
44
52
  def effective_stderr_encoding
45
- stderr_encoding || encoding || DEFAULT_ENCODING
53
+ canonical_encoding(stderr_encoding || encoding || DEFAULT_ENCODING)
46
54
  end
47
55
 
48
56
  private
@@ -66,6 +74,8 @@ module ProcessExecuter
66
74
  # - if the merge_output value is not a Boolean
67
75
  # - if merge_output: true and a stderr redirection is given
68
76
  # - if merge_output: true and stdout and stderr encodings are different
77
+ # - if a combined stdout/stderr redirection (e.g. `[:out, :err] =>
78
+ # destination`) is given and stdout and stderr encodings are different
69
79
  #
70
80
  # @param _key [Symbol] the option key (not used)
71
81
  # @param _value [Object] the option value (not used)
@@ -76,13 +86,65 @@ module ProcessExecuter
76
86
  errors << "merge_output must be true or false but was #{merge_output.inspect}"
77
87
  end
78
88
 
79
- return unless merge_output == true
89
+ if merge_output == true
90
+ errors << 'Cannot give merge_output: true AND a stderr redirection' if stderr_redirection_source
91
+ validate_uniform_capture_encoding('merge_output: true')
92
+ elsif combined_stdout_and_stderr_redirection?
93
+ validate_uniform_capture_encoding('a redirection that combines stdout and stderr')
94
+ end
95
+ end
80
96
 
81
- errors << 'Cannot give merge_output: true AND a stderr redirection' if stderr_redirection_source
97
+ # Note an error if the stdout and stderr encodings are different
98
+ #
99
+ # Used when both streams are captured into the single stdout buffer (via
100
+ # `merge_output: true` or a combined stdout/stderr redirection), which
101
+ # requires a single encoding.
102
+ #
103
+ # Encodings are compared in their canonical form, so equivalent
104
+ # representations (e.g. `Encoding::UTF_8` and `'UTF-8'`) are not
105
+ # rejected. A value that does not canonicalize to an Encoding (an
106
+ # unknown encoding name or an invalid type) is skipped here; the
107
+ # encoding option's own validator reports it.
108
+ #
109
+ # @param description [String] describes the option that requires a single encoding
110
+ # @return [Void]
111
+ # @api private
112
+ def validate_uniform_capture_encoding(description)
113
+ stdout_encoding = effective_stdout_encoding
114
+ stderr_encoding = effective_stderr_encoding
82
115
 
83
- return if effective_stdout_encoding == effective_stderr_encoding
116
+ return unless stdout_encoding.is_a?(Encoding) && stderr_encoding.is_a?(Encoding)
117
+ return if stdout_encoding == stderr_encoding
84
118
 
85
- errors << 'Cannot give merge_output: true AND give different encodings for stdout and stderr'
119
+ errors << "Cannot give #{description} AND give different encodings for stdout and stderr"
120
+ end
121
+
122
+ # Convert an encoding option value to its canonical Encoding object
123
+ #
124
+ # @param value [Encoding, String, Symbol, Object] the encoding option value
125
+ #
126
+ # @return [Encoding, Object, nil] the Encoding for a recognized value, nil
127
+ # for a String naming an unknown encoding, otherwise the value unchanged
128
+ #
129
+ # @api private
130
+ def canonical_encoding(value)
131
+ case value
132
+ when :binary then Encoding::BINARY
133
+ when :default_external then Encoding.default_external
134
+ when String then find_encoding(value)
135
+ else value
136
+ end
137
+ end
138
+
139
+ # Find an encoding by name, returning nil if the name is unknown
140
+ #
141
+ # @param name [String] the encoding name
142
+ # @return [Encoding, nil]
143
+ # @api private
144
+ def find_encoding(name)
145
+ Encoding.find(name)
146
+ rescue ::ArgumentError
147
+ nil
86
148
  end
87
149
 
88
150
  # Note an error if the encoding option is not valid
@@ -110,6 +110,19 @@ module ProcessExecuter
110
110
  (key = stderr_redirection_source) ? options_hash[key] : nil
111
111
  end
112
112
 
113
+ # Determine if a single redirection option key covers both stdout and stderr
114
+ #
115
+ # This is the case for a combined redirection such as `[:out, :err] =>
116
+ # destination`, where both {#stdout_redirection_source} and
117
+ # {#stderr_redirection_source} resolve to the same key.
118
+ #
119
+ # @return [Boolean]
120
+ # @api private
121
+ def combined_stdout_and_stderr_redirection?
122
+ key = stdout_redirection_source
123
+ !key.nil? && key.equal?(stderr_redirection_source)
124
+ end
125
+
113
126
  private
114
127
 
115
128
  # Define the allowed options
@@ -10,6 +10,12 @@ module ProcessExecuter
10
10
  # * `elapsed_time`: the seconds the command ran
11
11
  # * `timed_out?`: true if the process timed out
12
12
  #
13
+ # In the rare case that the timeout fires after the process was already
14
+ # reaped, its status is lost: the result delegates to a nil status and
15
+ # `timed_out?` is true. {#success?} and {#to_s} still work, but methods
16
+ # forwarded to the status (like `exitstatus` or `signaled?`) will raise
17
+ # NoMethodError.
18
+ #
13
19
  # @api public
14
20
  #
15
21
  class Result < SimpleDelegator
@@ -25,7 +31,8 @@ module ProcessExecuter
25
31
  #
26
32
  # ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:)
27
33
  #
28
- # @param status [Process::Status] the status to delegate to
34
+ # @param status [Process::Status, nil] the status to delegate to (nil when the
35
+ # timeout raced the wait for the process and the status was lost)
29
36
  #
30
37
  # @param command [Array] the command that was used to spawn the process
31
38
  #
@@ -70,7 +77,11 @@ module ProcessExecuter
70
77
  attr_reader :elapsed_time
71
78
 
72
79
  # @!attribute [r] timed_out?
73
- # True if the process timed out and was sent the SIGKILL signal
80
+ # True if the wait for the process was cut short by `timeout_after` elapsing
81
+ #
82
+ # A timed out process is sent the SIGKILL signal, unless it had already
83
+ # exited in the moment between the timeout firing and the kill (the
84
+ # raced-timeout corner case described above).
74
85
  # @example
75
86
  # result = ProcessExecuter.spawn_with_timeout('sleep 10', timeout_after: 0.01)
76
87
  # result.timed_out? # => true
@@ -3,5 +3,5 @@
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
5
  # @return [String]
6
- VERSION = '4.0.3'
6
+ VERSION = '4.1.0'
7
7
  end
@@ -32,6 +32,26 @@ module ProcessExecuter
32
32
  # * `timeout_after: <Numeric, nil>`: the amount of time (in seconds) to wait before
33
33
  # signaling the process with SIGKILL. 0 or nil means no timeout.
34
34
  #
35
+ # When a timeout is given, the command is spawned into its own process group
36
+ # (`pgroup: true` on POSIX, `new_pgroup: true` on Windows) unless the caller
37
+ # passes a `pgroup`/`new_pgroup` option themselves. Note that a new process
38
+ # group is a background group for any terminal the command inherits, so an
39
+ # interactive command that reads the terminal is stopped by `SIGTTIN` and
40
+ # then killed when the timeout fires. A caller who needs an interactive
41
+ # command to stay in the foreground process group can pass their own
42
+ # `pgroup` option (for example, `pgroup: Process.getpgrp`).
43
+ #
44
+ # On timeout, the whole process group is killed so that descendant
45
+ # processes still in that group do not outlive the timeout. A descendant
46
+ # that started its own session or moved to another process group (a
47
+ # daemon, for example) is not killed. If the group can not be killed (for
48
+ # instance, the caller placed the command in an existing process group),
49
+ # only the direct child is killed.
50
+ #
51
+ # Killing the process group is only supported on POSIX platforms. On
52
+ # Windows, Ruby's `Process.kill` cannot signal a process group, so only the
53
+ # direct child is killed and descendant processes may survive the timeout.
54
+ #
35
55
  # Returns a {Result} object. The {Result} class is a decorator for
36
56
  # [Process::Status](https://docs.ruby-lang.org/en/3.4/Process/Status.html) that
37
57
  # provides additional attributes about the command's status. This includes the
@@ -55,7 +75,10 @@ module ProcessExecuter
55
75
  # the following options are supported: `:timeout_after`
56
76
  #
57
77
  # @option options_hash [Numeric] :timeout_after the amount of time (in seconds)
58
- # to wait before signaling the process with SIGKILL
78
+ # to wait before killing the process -- and, on POSIX platforms when the
79
+ # effective spawn options make the command a new process group leader
80
+ # (set automatically, or by an explicit `pgroup: true` or `pgroup: 0`),
81
+ # its whole process group
59
82
  #
60
83
  # @overload spawn_with_timeout(*command, options)
61
84
  #
@@ -209,7 +232,10 @@ module ProcessExecuter
209
232
  # and its result at the info level
210
233
  #
211
234
  # @option options_hash [Numeric] :timeout_after the amount of time (in seconds)
212
- # to wait before signaling the process with SIGKILL
235
+ # to wait before killing the process -- and, on POSIX platforms when the
236
+ # effective spawn options make the command a new process group leader
237
+ # (set automatically, or by an explicit `pgroup: true` or `pgroup: 0`),
238
+ # its whole process group
213
239
  #
214
240
  # @overload run(*command, options)
215
241
  #
@@ -279,6 +305,12 @@ module ProcessExecuter
279
305
  # <destination>` or `err: <destination>`). These redirections will receive the
280
306
  # output in addition to the internal capture.
281
307
  #
308
+ # A combined redirection whose key covers both stdout and stderr (e.g. `[:out,
309
+ # :err] => <destination>`) behaves like `merge_output: true`: both streams are
310
+ # interleaved into the `#stdout` capture and `#stderr` is empty. As with
311
+ # `merge_output: true`, a `ProcessExecuter::ArgumentError` is raised if
312
+ # different encodings are given for stdout and stderr.
313
+ #
282
314
  # Unless told otherwise, the internally captured output is assumed to be in UTF-8
283
315
  # encoding. This assumption can be changed with the `encoding`,
284
316
  # `stdout_encoding`, or `stderr_encoding` options. These options accept any
@@ -4,14 +4,43 @@ These tests verify that `Process.spawn`, `Process.wait`, and `Process.wait2` wor
4
4
  correctly across different Ruby implementations and operating systems.
5
5
 
6
6
  This test suite is particularly important for verifying JRuby behavior on Windows,
7
- where historically there have been issues with subprocess status reporting.
7
+ where historically there have been issues with subprocess status reporting. See
8
+ [jruby/jruby#7515](https://github.com/jruby/jruby/issues/7515).
8
9
 
9
10
  ## Tests
10
11
 
11
12
  The test suite includes:
12
13
 
13
14
  * Test that `Process#wait` sets the global `$CHILD_STATUS` variable
15
+ * Test that `Process#wait` reports the exit status of a child that failed
16
+ * Test that `Process#wait` blocks until the child exits
14
17
  * Test that `Process#wait2` returns a non-nil status
18
+ * Test that `Process#wait2` returns the pid and status of a child that failed
19
+
20
+ ## Spawn backends
21
+
22
+ Set `SPAWN_BACKEND` to choose which spawn implementation the examples exercise:
23
+
24
+ | Value | Meaning |
25
+ | --- | --- |
26
+ | `stock` (default) | Whatever the running Ruby provides |
27
+ | `subspawn` | [byteit101/subspawn](https://github.com/byteit101/subspawn) replaces `Process.spawn` and the `wait` family |
28
+
29
+ JRuby has a built-in opt-in for SubSpawn, `USE_SUBSPAWN=true`, but it refuses to
30
+ honor it on the one platform this suite cares about: `Ruby.java` logs
31
+ `env USE_SUBSPAWN=true is unsupported on Windows at this time` and loads nothing.
32
+ So `spec_helper.rb` requires `subspawn/replace-builtin` itself instead.
33
+
34
+ Two things keep the `subspawn` backend from being a plain `gem install subspawn --pre`:
35
+
36
+ * The published `subspawn` 0.2.0.pre1 cannot be installed on JRuby at all. Its
37
+ `engine-hacks` dependency was only ever pushed as a `ruby` platform gem carrying
38
+ a C extension; the `java` platform build that its gemspec provides for was never
39
+ published, so RubyGems tries to compile the C extension under JRuby.
40
+ * master is newer than the RC and carries fixes the RC does not have.
41
+
42
+ So the workflow clones the repo and builds the gemspecs with the active Ruby, which
43
+ produces the java-platform `engine-hacks` gem.
15
44
 
16
45
  ## Running the Tests
17
46
 
@@ -27,10 +56,18 @@ Alternatively, you can stay at the root and run:
27
56
  bundle exec rspec process_spawn_test/spec/test_spec.rb
28
57
  ```
29
58
 
59
+ SubSpawn is not in that Gemfile. To run the `subspawn` backend, install it as a
60
+ system gem and run `rspec` directly, outside the bundle:
61
+
62
+ ```bash
63
+ SPAWN_BACKEND=subspawn rspec spec/test_spec.rb
64
+ ```
65
+
30
66
  ## GitHub Actions Workflow
31
67
 
32
68
  The workflow file `.github/workflows/process-spawn-test.yml` can be manually triggered to run these tests on:
33
69
  - MRI Ruby and JRuby
34
70
  - Ubuntu (Linux) and Windows
71
+ - the `stock` and `subspawn` spawn backends (`subspawn` on JRuby only)
35
72
 
36
73
  To run the workflow, go to the Actions tab in GitHub and select "Process.spawn Test" from the workflow list.
@@ -2,6 +2,17 @@
2
2
 
3
3
  require 'rspec'
4
4
 
5
+ # Which spawn implementation these examples exercise: 'stock' (whatever the
6
+ # running Ruby provides) or 'subspawn' (byteit101/subspawn).
7
+ SPAWN_BACKEND = ENV.fetch('SPAWN_BACKEND', 'stock')
8
+
9
+ # JRuby has a built-in opt-in for SubSpawn, `USE_SUBSPAWN=true`, but it refuses
10
+ # to honor it on the one platform jruby/jruby#7515 is about: Ruby.java logs
11
+ # "env USE_SUBSPAWN=true is unsupported on Windows at this time" and loads
12
+ # nothing. Require the replacement shim directly so the Windows job actually
13
+ # runs against SubSpawn.
14
+ require 'subspawn/replace-builtin' if SPAWN_BACKEND == 'subspawn'
15
+
5
16
  RSpec.configure do |config|
6
17
  # Enable flags like --only-failures and --next-failure
7
18
  config.example_status_persistence_file_path = '.rspec_status'
@@ -12,4 +23,11 @@ RSpec.configure do |config|
12
23
  config.expect_with :rspec do |c|
13
24
  c.syntax = :expect
14
25
  end
26
+
27
+ config.before(:suite) do
28
+ warn "Ruby: #{RUBY_DESCRIPTION}"
29
+ warn "spawn backend: #{SPAWN_BACKEND}"
30
+ # Records whether the shim really took effect, and which backend it picked.
31
+ warn "SubSpawn: #{defined?(SubSpawn) ? SubSpawn::Platform : '(not loaded)'}"
32
+ end
15
33
  end
@@ -9,6 +9,20 @@ RSpec.describe 'Process#wait' do
9
9
  expect($CHILD_STATUS).not_to be_nil
10
10
  expect($CHILD_STATUS.pid).to eq(pid)
11
11
  end
12
+
13
+ it 'reports the exit status of a child that failed' do
14
+ pid = Process.spawn('ruby', '-e', 'exit 42')
15
+ Process.wait(pid)
16
+ expect($CHILD_STATUS&.exitstatus).to eq(42)
17
+ end
18
+
19
+ it 'blocks until the child exits' do
20
+ pid = Process.spawn('ruby', '-e', 'sleep 2')
21
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ Process.wait(pid)
23
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
24
+ expect(elapsed).to be >= 1.5
25
+ end
12
26
  end
13
27
 
14
28
  RSpec.describe 'Process#wait2' do
@@ -18,4 +32,11 @@ RSpec.describe 'Process#wait2' do
18
32
  expect(status).not_to be_nil
19
33
  expect(status.pid).to eq(pid)
20
34
  end
35
+
36
+ it 'returns the pid and status of a child that failed' do
37
+ pid = Process.spawn('ruby', '-e', 'exit 42')
38
+ reaped_pid, status = Process.wait2(pid)
39
+ expect(reaped_pid).to eq(pid)
40
+ expect(status&.exitstatus).to eq(42)
41
+ end
21
42
  end
@@ -4,6 +4,7 @@
4
4
  ".": {
5
5
  "release-type": "ruby",
6
6
  "package-name": "process_executer",
7
+ "release-as": "4.1.0",
7
8
  "changelog-path": "CHANGELOG.md",
8
9
  "version-file": "lib/process_executer/version.rb",
9
10
  "bump-minor-pre-major": true,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_executer
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.3
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Couball
@@ -237,11 +237,13 @@ extensions: []
237
237
  extra_rdoc_files: []
238
238
  files:
239
239
  - ".commitlintrc.yml"
240
+ - ".editorconfig"
240
241
  - ".husky/commit-msg"
241
242
  - ".markdownlint.yml"
242
243
  - ".release-please-manifest.json"
243
244
  - ".rspec"
244
245
  - ".rubocop.yml"
246
+ - ".vscode/extensions.json"
245
247
  - ".yardopts"
246
248
  - CHANGELOG.md
247
249
  - Gemfile
@@ -293,8 +295,8 @@ metadata:
293
295
  allowed_push_host: https://rubygems.org
294
296
  homepage_uri: https://github.com/main-branch/process_executer
295
297
  source_code_uri: https://github.com/main-branch/process_executer
296
- documentation_uri: https://rubydoc.info/gems/process_executer/4.0.3
297
- changelog_uri: https://rubydoc.info/gems/process_executer/4.0.3/file/CHANGELOG.md
298
+ documentation_uri: https://rubydoc.info/gems/process_executer/4.1.0
299
+ changelog_uri: https://rubydoc.info/gems/process_executer/4.1.0/file/CHANGELOG.md
298
300
  rubygems_mfa_required: 'true'
299
301
  rdoc_options: []
300
302
  require_paths:
@@ -312,7 +314,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
312
314
  requirements:
313
315
  - 'Platform: Mac, Linux, or Windows'
314
316
  - 'Ruby: MRI 3.1 or later, TruffleRuby 24 or later, or JRuby 9.4 or later'
315
- rubygems_version: 4.0.6
317
+ rubygems_version: 4.0.16
316
318
  specification_version: 4
317
319
  summary: Enhanced subprocess execution with timeouts, output capture, and flexible
318
320
  redirection