diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e9e56..05e712b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,11 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', head, jruby, truffleruby ] - # CRuby < 2.6 does not support macos-arm64, so test those on amd64 instead - # JRuby 9.4.7.0 does not have native console support on macos-arm64: https://github.com/jruby/jruby/issues/8271 + ruby: [ '3.2', '3.3', '3.4', '4.0', head, jruby, truffleruby ] + # JRuby does not have native console support on macos-arm64: https://github.com/jruby/jruby/issues/8271 include: - - { os: macos-13, ruby: '2.4' } - - { os: macos-13, ruby: '2.5' } - { os: macos-13, ruby: jruby } exclude: - - { os: macos-latest, ruby: '2.4' } - - { os: macos-latest, ruby: '2.5' } - { os: macos-latest, ruby: jruby } - { os: windows-latest, ruby: truffleruby } # fails to load rspec: RuntimeError: CRITICAL: RUBYGEMS_ACTIVATION_MONITOR.owned?: before false -> after true @@ -24,9 +19,29 @@ jobs: env: CHILDPROCESS_UNSET: should-be-unset steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake spec + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - run: bundle exec rubocop + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - run: bundle exec bundler-audit check --update diff --git a/.gitignore b/.gitignore index 36ae330..62fc70c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,10 +19,11 @@ tmtags ## PROJECT::GENERAL coverage rdoc +doc +.yardoc pkg .rbx Gemfile.lock -.ruby-version .bundle ## PROJECT::SPECIFIC diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9183584 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,109 @@ +plugins: + - rubocop-performance + - rubocop-rspec + +AllCops: + NewCops: enable + TargetRubyVersion: 3.2 + SuggestExtensions: false + Exclude: + - 'pkg/**/*' + - 'vendor/**/*' + +# This is a small, stable library with a deliberately plain style; +# these formatting cops fight the existing (consistent) house style +# more than they help, so they're tuned down rather than left default. +Layout/LineLength: + Max: 120 + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Metrics/AbcSize: + Max: 30 + +Metrics/MethodLength: + Max: 30 + +Metrics/ClassLength: + Max: 150 + +Metrics/CyclomaticComplexity: + Max: 10 + +Metrics/PerceivedComplexity: + Max: 10 + +# ChildProcess is the library's single namespace module and legitimately +# holds all of the platform-detection API; splitting it up would hurt more +# than a slightly relaxed line budget. +Metrics/ModuleLength: + Max: 200 + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + +# Standard Bundler::GemHelper Rakefile boilerplate. +Style/MixinUsage: + Exclude: + - 'Rakefile' + +# `set_exit_code` etc. are private helpers, not public accessors -- the +# `set_`/`has_`/`is_` prefixes they use are clearer here than the cops' +# preferred alternatives (e.g. `to_io?` would be actively confusing next +# to the real `Kernel#to_io`). +Naming/AccessorMethodName: + Enabled: false + +Naming/PredicatePrefix: + Enabled: false + +# The project's gem/module name is "childprocess" (no underscore) +# throughout -- file names already match that convention, not the cop's +# ActiveSupport-style `child_process` inflection of the `ChildProcess` +# constant. +RSpec/SpecFilePathFormat: + Enabled: false + +# `before(:all)`/`after(:all)` are used once, deliberately, to save and +# restore process-global RbConfig state around the OS-detection specs -- +# not to share database/records state across examples. +RSpec/BeforeAfterAll: + Enabled: false + +RSpec/InstanceVariable: + Exclude: + - 'spec/childprocess_spec.rb' + +# These specs `eval` the `Hash#inspect`/`Array#inspect` output that a +# child process (spawned by the very same spec, from a tempfile the spec +# itself wrote) reports its ENV/ARGV as -- not attacker-controlled input. +Security/Eval: + Exclude: + - 'spec/childprocess_spec.rb' + +# Dev dependencies are declared in the gemspec (not the Gemfile) so that +# `gem build`/`rake` consumers of a checked-out clone get a consistent, +# pinned toolchain without needing a separate Gemfile entry per tool. +Gemspec/DevelopmentDependencies: + Enabled: false + +RSpec/ExampleLength: + Max: 35 + +RSpec/MultipleExpectations: + Max: 10 + +RSpec/NestedGroups: + Max: 5 + +RSpec/DescribeClass: + Exclude: + - 'spec/childprocess_spec.rb' + - 'spec/io_spec.rb' + - 'spec/pid_behavior.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..d13e837 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/Gemfile b/Gemfile index fa55d04..d692a4a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,6 @@ +# frozen_string_literal: true + source 'https://rubygems.org' -# Specify your gem's dependencies in child_process.gemspec +# Specify your gem's dependencies in childprocess.gemspec gemspec - -# Used for local development/testing only -gem 'rake' - -# Newer versions of term-ansicolor (used by coveralls) do not work on Ruby 2.4 -gem 'term-ansicolor', '< 1.8.0' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.5') diff --git a/README.md b/README.md index 165d862..9a59450 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,16 @@ a standalone library. [![CI](https://github.com/enkessler/childprocess/actions/workflows/ci.yml/badge.svg)](https://github.com/enkessler/childprocess/actions/workflows/ci.yml) ![Gem Version](https://img.shields.io/gem/v/childprocess) [![Code Climate](https://codeclimate.com/github/enkessler/childprocess.svg)](https://codeclimate.com/github/enkessler/childprocess) -[![Coverage Status](https://coveralls.io/repos/enkessler/childprocess/badge.svg?branch=master)](https://coveralls.io/r/enkessler/childprocess?branch=master) + +Test coverage is measured locally with [SimpleCov](https://github.com/simplecov-ruby/simplecov) +(`bundle exec rake spec` prints the summary; the full report is written to `coverage/`) -- the +suite maintains 100% line coverage. # Requirements -* Ruby 2.4+, JRuby 9+ +* Ruby 3.2+, JRuby 9+, TruffleRuby +* Tested against Ruby 3.2, 3.3, 3.4, 4.0 and `head` in CI (see + [.github/workflows/ci.yml](.github/workflows/ci.yml)) # Usage @@ -183,11 +188,21 @@ ChildProcess.logger = logger ChildProcess 5+ uses `Process.spawn` from the Ruby core library for maximum portability. +# Development + +``` +bundle install +bundle exec rake spec # run the test suite (100% line coverage enforced via SimpleCov) +bundle exec rubocop # lint +bundle exec yard doc # generate API docs into doc/ +bundle exec bundler-audit check --update # dependency security audit +``` + # Note on Patches/Pull Requests 1. Fork it -2. Create your feature branch (off of the development branch) - `git checkout -b my-new-feature dev` +2. Create your feature branch (off of `master`) + `git checkout -b my-new-feature` 3. Commit your changes `git commit -am 'Add some feature'` 4. Push to the branch @@ -198,14 +213,12 @@ ChildProcess 5+ uses `Process.spawn` from the Ruby core library for maximum port When publishing a new gem release: -1. Ensure [latest build is green on the `dev` branch](https://travis-ci.org/enkessler/childprocess/branches) +1. Ensure the [latest build is green on `master`](https://github.com/enkessler/childprocess/actions/workflows/ci.yml) 2. Ensure [CHANGELOG](CHANGELOG.md) is updated 3. Ensure [version is bumped](lib/childprocess/version.rb) following [Semantic Versioning](https://semver.org/) -4. Merge the `dev` branch into `master`: `git checkout master && git merge dev` -5. Ensure [latest build is green on the `master` branch](https://travis-ci.org/enkessler/childprocess/branches) -6. Build gem from the green `master` branch: `git checkout master && gem build childprocess.gemspec` -7. Push gem to RubyGems: `gem push childprocess-.gem` -8. Tag commit with version, annotated with release notes: `git tag -a ` +4. Build the gem: `gem build childprocess.gemspec` +5. Push gem to RubyGems: `gem push childprocess-.gem` +6. Tag commit with version, annotated with release notes: `git tag -a ` # Copyright diff --git a/Rakefile b/Rakefile index 5e4d38e..db6cceb 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rubygems' require 'rake' require 'tmpdir' @@ -5,57 +7,57 @@ require 'tmpdir' require 'bundler' Bundler::GemHelper.install_tasks -include Rake::DSL if defined?(::Rake::DSL) +include Rake::DSL if defined?(Rake::DSL) require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) do |spec| - spec.ruby_opts = "-I lib:spec -w" + spec.ruby_opts = '-I lib:spec -w' spec.pattern = 'spec/**/*_spec.rb' end desc 'Run specs for rcov' RSpec::Core::RakeTask.new(:rcov) do |spec| - spec.ruby_opts = "-I lib:spec" + spec.ruby_opts = '-I lib:spec' spec.pattern = 'spec/**/*_spec.rb' spec.rcov = true spec.rcov_opts = %w[--exclude spec,ruby-debug,/Library/Ruby,.gem --include lib/childprocess] end -task :default => :spec +task default: :spec begin require 'yard' YARD::Rake::YardocTask.new rescue LoadError task :yardoc do - abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard" + abort 'YARD is not available. In order to run yardoc, you must: sudo gem install yard' end end task :clean do - rm_rf "pkg" - rm_rf "childprocess.jar" + rm_rf 'pkg' + rm_rf 'childprocess.jar' end desc 'Create jar to bundle in selenium-webdriver' -task :jar => [:clean, :build] do - tmpdir = Dir.mktmpdir("childprocess-jar") +task jar: %i[clean build] do + tmpdir = Dir.mktmpdir('childprocess-jar') gem_to_package = Dir['pkg/*.gem'].first - gem_name = File.basename(gem_to_package, ".gem") - p :gem_to_package => gem_to_package, :gem_name => gem_name + gem_name = File.basename(gem_to_package, '.gem') + p gem_to_package: gem_to_package, gem_name: gem_name sh "gem install -i #{tmpdir} #{gem_to_package} --ignore-dependencies --no-rdoc --no-ri" sh "jar cf childprocess.jar -C #{tmpdir}/gems/#{gem_name}/lib ." - sh "jar tf childprocess.jar" + sh 'jar tf childprocess.jar' end task :env do - $:.unshift File.expand_path("../lib", __FILE__) + $LOAD_PATH.unshift File.expand_path('lib', __dir__) require 'childprocess' end desc 'Calculate size of posix_spawn structs for the current platform' -task :generate => :env do +task generate: :env do require 'childprocess/tools/generator' ChildProcess::Tools::Generator.generate end diff --git a/childprocess.gemspec b/childprocess.gemspec index 3fa00c6..21cad08 100644 --- a/childprocess.gemspec +++ b/childprocess.gemspec @@ -1,28 +1,41 @@ -# -*- encoding: utf-8 -*- -$:.push File.expand_path("../lib", __FILE__) -require "childprocess/version" +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'childprocess/version' Gem::Specification.new do |s| - s.name = "childprocess" + s.name = 'childprocess' s.version = ChildProcess::VERSION s.platform = Gem::Platform::RUBY - s.authors = ["Jari Bakken", "Eric Kessler", "Shane da Silva"] - s.email = ["morrow748@gmail.com", "shane@dasilva.io"] - s.homepage = "https://github.com/enkessler/childprocess" - s.summary = %q{A simple and reliable solution for controlling external programs running in the background on any Ruby / OS combination.} - s.description = %q{This gem aims at being a simple and reliable solution for controlling external programs running in the background on any Ruby / OS combination.} + s.authors = ['Jari Bakken', 'Eric Kessler', 'Shane da Silva'] + s.email = ['morrow748@gmail.com', 'shane@dasilva.io'] + s.homepage = 'https://github.com/enkessler/childprocess' + s.summary = 'A simple and reliable solution for controlling external programs running in the background.' + s.description = 'This gem aims at being a simple and reliable solution for controlling external programs ' \ + 'running in the background on any Ruby / OS combination.' s.license = 'MIT' + s.metadata = { + 'bug_tracker_uri' => 'https://github.com/enkessler/childprocess/issues', + 'changelog_uri' => 'https://github.com/enkessler/childprocess/blob/master/CHANGELOG.md', + 'source_code_uri' => 'https://github.com/enkessler/childprocess/', + 'rubygems_mfa_required' => 'true' + } s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- spec/*`.split("\n") - s.require_paths = ["lib"] + s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.4.0' + s.required_ruby_version = '>= 3.2' - s.add_dependency "logger", "~> 1.5" + s.add_dependency 'logger', '~> 1.5' - s.add_development_dependency "rspec", "~> 3.0" - s.add_development_dependency "yard", "~> 0.0" - s.add_development_dependency 'coveralls', '< 1.0' + s.add_development_dependency 'bundler-audit', '~> 0.9' + s.add_development_dependency 'rake', '~> 13.0' + s.add_development_dependency 'rspec', '~> 3.13' + s.add_development_dependency 'rubocop', '~> 1.88' + s.add_development_dependency 'rubocop-performance', '~> 1.26' + s.add_development_dependency 'rubocop-rspec', '~> 3.10' + s.add_development_dependency 'simplecov', '~> 0.22' + s.add_development_dependency 'yard', '~> 0.9' end diff --git a/lib/childprocess.rb b/lib/childprocess.rb index cfc5440..b549d19 100644 --- a/lib/childprocess.rb +++ b/lib/childprocess.rb @@ -1,18 +1,55 @@ +# frozen_string_literal: true + require 'childprocess/version' require 'childprocess/errors' require 'childprocess/abstract_process' require 'childprocess/abstract_io' require 'childprocess/process_spawn_process' -require "fcntl" +require 'fcntl' require 'logger' +# +# A simple and reliable solution for controlling external programs running +# in the background on any Ruby / OS combination. +# +# @example Basic usage +# process = ChildProcess.build("ruby", "-e", "sleep") +# process.io.inherit! +# process.start +# process.poll_for_exit(10) +# +# @see ChildProcess.build the main entry point module ChildProcess - @posix_spawn = false class << self + # @return [Logger] the logger used for internal debug/warning messages; + # defaults to a {Logger} writing to `$stderr` attr_writer :logger + # + # Set this to true to enable experimental use of posix_spawn. + # + # @return [Boolean] + attr_writer :posix_spawn + + # + # Build a new child process for the given command and arguments, + # choosing the concrete {AbstractProcess} subclass appropriate for the + # current platform ({Unix::Process} or {Windows::Process}). Also + # available as {.build}. + # + # The command is never run through a shell -- e.g. shell built-ins, + # globbing and `.bat`/`.com` extensions on Windows won't work unless + # you invoke the relevant interpreter explicitly (`"cmd.exe", "/c", + # "..."` or `"ruby", "-S", "..."`). + # + # @param args [Array] the command and its arguments + # @raise [ArgumentError] if any argument is not a String + # @raise [Error] if the current platform isn't supported + # @return [AbstractProcess] + + # rubocop:disable Style/ArgumentsForwarding -- named for the sake of the @param doc above def new(*args) case os when :macosx, :linux, :solaris, :bsd, :cygwin, :aix @@ -23,10 +60,14 @@ def new(*args) raise Error, "unsupported platform #{platform_name.inspect}" end end - alias_method :build, :new + # rubocop:enable Style/ArgumentsForwarding + alias build new + + # + # @return [Logger] the logger used for internal debug/warning messages def logger - return @logger if defined?(@logger) and @logger + return @logger if defined?(@logger) && @logger @logger = Logger.new($stderr) @logger.level = $DEBUG ? Logger::DEBUG : Logger::INFO @@ -34,51 +75,79 @@ def logger @logger end + # + # @return [Symbol] the detected OS, same as {.os} + def platform os end + # + # @return [String] the detected architecture and OS, e.g. `"x86_64-linux"` + def platform_name @platform_name ||= "#{arch}-#{os}" end + # + # @return [Boolean] `true` unless running on Windows + def unix? !windows? end + # + # @return [Boolean] `true` if running on Linux + def linux? os == :linux end + # + # @return [Boolean] `true` if running under the JRuby engine + def jruby? RUBY_ENGINE == 'jruby' end + # + # @return [Boolean] `true` if running on Windows + def windows? os == :windows end + # + # Whether {.posix_spawn=} was set to `true`, or the + # `CHILDPROCESS_POSIX_SPAWN` environment variable is `"1"` or `"true"`. + # + # @return [Boolean] + def posix_spawn_chosen_explicitly? - @posix_spawn || %w[1 true].include?(ENV['CHILDPROCESS_POSIX_SPAWN']) + @posix_spawn || %w[1 true].include?(ENV.fetch('CHILDPROCESS_POSIX_SPAWN', nil)) end + # + # ChildProcess 5+ always uses `Process.spawn` and has no separate + # posix_spawn backend; kept for backwards API compatibility with + # earlier ChildProcess versions. + # + # @return [Boolean] always `false` + def posix_spawn? false end # - # Set this to true to enable experimental use of posix_spawn. - # - - def posix_spawn=(bool) - @posix_spawn = bool - end + # @return [Symbol] the detected OS, one of `:macosx`, `:linux`, + # `:windows`, `:cygwin`, `:solaris`, `:bsd`, `:aix` + # @raise [Error] if the OS could not be determined def os return :windows if ENV['FAKE_WINDOWS'] == 'true' - @os ||= ( - require "rbconfig" + @os ||= begin + require 'rbconfig' host_os = RbConfig::CONFIG['host_os'].downcase case host_os @@ -99,28 +168,31 @@ def os else raise Error, "unknown os: #{host_os.inspect}" end - ) + end end + # + # @return [String] the detected CPU architecture, e.g. `"x86_64"`, `"i386"`, `"powerpc"` + def arch - @arch ||= ( + @arch ||= begin host_cpu = RbConfig::CONFIG['host_cpu'].downcase case host_cpu when /i[3456]86/ if workaround_older_macosx_misreported_cpu? # Workaround case: older 64-bit Darwin Rubies misreported as i686 - "x86_64" + 'x86_64' else - "i386" + 'i386' end when /amd64|x86_64/ - "x86_64" + 'x86_64' when /ppc|powerpc/ - "powerpc" + 'powerpc' else host_cpu end - ) + end end # @@ -128,26 +200,20 @@ def arch # parent process. This helper provides a cross-platform way of making sure # that doesn't happen for the given file/io. # + # @param file [IO] the file/IO to set close-on-exec for + # @raise [Error] if `file` doesn't respond to `close_on_exec=` + # @return [void] def close_on_exec(file) - if file.respond_to?(:close_on_exec=) - file.close_on_exec = true - else + unless file.respond_to?(:close_on_exec=) raise Error, "not sure how to set close-on-exec for #{file.inspect} on #{platform_name.inspect}" end + + file.close_on_exec = true end private - def warn_once(msg) - @warnings ||= {} - - unless @warnings[msg] - @warnings[msg] = true - logger.warn msg - end - end - # Workaround: detect the situation that an older Darwin Ruby is actually # 64-bit, but is misreporting cpu as i686, which would imply 32-bit. # @@ -162,12 +228,16 @@ def workaround_older_macosx_misreported_cpu? def is_64_bit? 1.size == 8 end + end +end - end # class << self -end # ChildProcess - +# :nocov: +# Exactly one of these two branches can ever execute for a given OS/process, +# so this platform dispatch can never show 100% branch coverage from a +# single test run (regardless of which OS runs the suite). if ChildProcess.windows? require 'childprocess/windows' else require 'childprocess/unix' end +# :nocov: diff --git a/lib/childprocess/abstract_io.rb b/lib/childprocess/abstract_io.rb index 5d159b2..a68ddb3 100644 --- a/lib/childprocess/abstract_io.rb +++ b/lib/childprocess/abstract_io.rb @@ -1,22 +1,56 @@ +# frozen_string_literal: true + module ChildProcess + # Configures the IO streams (stdin/stdout/stderr) of a child process + # before it is started. Accessed via {AbstractProcess#io}. + # + # What counts as a valid IO object is platform-specific (see + # {Unix::IO#check_type} and {Windows::IO#check_type}), so this class is + # never instantiated directly. class AbstractIO - attr_reader :stderr, :stdout, :stdin + # @return [IO, nil] the stream the child's stderr is redirected to, or `nil` if unset + attr_reader :stderr + + # @return [IO, nil] the stream the child's stdout is redirected to, or `nil` if unset + attr_reader :stdout + + # @return [IO, nil] the write end of the duplex pipe, once the process has started + # with {AbstractProcess#duplex} set to `true` + attr_reader :stdin + + # + # Make the child inherit stdout/stderr from the current process. + # + # @return [void] def inherit! - @stdout = STDOUT - @stderr = STDERR + @stdout = $stdout + @stderr = $stderr end + # + # @param io [IO] where the child's stderr should be redirected to + # @raise [ArgumentError, TypeError] if `io` is not a valid IO-like object for this platform + # @return [IO] + def stderr=(io) check_type io @stderr = io end + # + # @param io [IO] where the child's stdout should be redirected to + # @raise [ArgumentError, TypeError] if `io` is not a valid IO-like object for this platform + # @return [IO] + def stdout=(io) check_type io @stdout = io end + # + # Sets the read end of the duplex pipe as {#stdin}, once the process + # has started with {AbstractProcess#duplex} set to `true`. # # @api private # @@ -28,9 +62,9 @@ def _stdin=(io) private - def check_type(io) - raise SubclassResponsibility, "check_type" + # @raise [ArgumentError, TypeError] always, unless overridden by a subclass + def check_type(_io) + raise SubclassResponsibility, 'check_type' end - end end diff --git a/lib/childprocess/abstract_process.rb b/lib/childprocess/abstract_process.rb index 93fd064..4ba749b 100644 --- a/lib/childprocess/abstract_process.rb +++ b/lib/childprocess/abstract_process.rb @@ -1,27 +1,44 @@ +# frozen_string_literal: true + module ChildProcess + # Represents a single child process and its lifecycle: starting it, + # inspecting whether it's alive, and stopping it. + # + # This class is never instantiated directly -- use {ChildProcess.build} + # to get a platform-appropriate instance (currently always a subclass of + # {ProcessSpawnProcess}). Methods that are the subclass's responsibility + # to implement raise {SubclassResponsibility} here. class AbstractProcess + # Seconds to sleep between polls in {#poll_for_exit}. POLL_INTERVAL = 0.1 + # @return [Integer, nil] the exit code once the process has exited, `nil` until then attr_reader :exit_code # # Set this to true if you do not care about when or if the process quits. # + # @return [Boolean] attr_accessor :detach # # Set this to true if you want to write to the process' stdin (process.io.stdin) # + # @return [Boolean] attr_accessor :duplex # - # Modify the child's environment variables + # Modify the child's environment variables. Keys and values are + # converted to Strings; a `nil` value unsets that variable for the + # child (rather than passing through the parent's value). # + # @return [Hash] attr_reader :environment # # Set the child's current working directory. # + # @return [String, nil] attr_accessor :cwd # @@ -30,6 +47,7 @@ class AbstractProcess # This can be used to make sure that all grandchildren are killed # when the child process dies. # + # @return [Boolean] attr_accessor :leader # @@ -39,10 +57,12 @@ class AbstractProcess # @see ChildProcess.build # + # @param args [Array] the command and its arguments, e.g. + # `("ruby", "-e", "puts 1")`. The command is never run through a + # shell (see {ChildProcess.build}). + # @raise [ArgumentError] if any argument is not a String def initialize(*args) - unless args.all? { |e| e.kind_of?(String) } - raise ArgumentError, "all arguments must be String: #{args.inspect}" - end + raise ArgumentError, "all arguments must be String: #{args.inspect}" unless args.all?(String) @args = args @started = false @@ -58,9 +78,10 @@ def initialize(*args) # # Returns a ChildProcess::AbstractIO subclass to configure the child's IO streams. # + # @return [AbstractIO] def io - raise SubclassResponsibility, "io" + raise SubclassResponsibility, 'io' end # @@ -68,7 +89,7 @@ def io # def pid - raise SubclassResponsibility, "pid" + raise SubclassResponsibility, 'pid' end # @@ -90,8 +111,8 @@ def start # @param [Integer] timeout (3) Seconds to wait before trying the next method. # - def stop(timeout = 3) - raise SubclassResponsibility, "stop" + def stop(timeout = 3) # rubocop:disable Lint/UnusedMethodArgument -- documents the subclass contract + raise SubclassResponsibility, 'stop' end # @@ -101,7 +122,7 @@ def stop(timeout = 3) # def wait - raise SubclassResponsibility, "wait" + raise SubclassResponsibility, 'wait' end # @@ -111,7 +132,7 @@ def wait # def exited? - raise SubclassResponsibility, "exited?" + raise SubclassResponsibility, 'exited?' end # @@ -148,6 +169,9 @@ def crashed? # Wait for the process to exit, raising a ChildProcess::TimeoutError if # the timeout expires. # + # @param timeout [Numeric] seconds to wait before giving up + # @raise [TimeoutError] if the process is still alive after `timeout` seconds + # @return [void] def poll_for_exit(timeout) log "polling #{timeout} seconds for exit" @@ -157,15 +181,15 @@ def poll_for_exit(timeout) sleep POLL_INTERVAL end - unless ok - raise TimeoutError, "process still alive after #{timeout} seconds" - end + return if ok + + raise TimeoutError, "process still alive after #{timeout} seconds" end private def launch_process - raise SubclassResponsibility, "launch_process" + raise SubclassResponsibility, 'launch_process' end def detach? @@ -181,12 +205,11 @@ def leader? end def log(*args) - ChildProcess.logger.debug "#{self.inspect} : #{args.inspect}" + ChildProcess.logger.debug "#{inspect} : #{args.inspect}" end def assert_started - raise Error, "process not started" unless started? + raise Error, 'process not started' unless started? end - - end # AbstractProcess -end # ChildProcess + end +end diff --git a/lib/childprocess/errors.rb b/lib/childprocess/errors.rb index c6a48b3..373aef6 100644 --- a/lib/childprocess/errors.rb +++ b/lib/childprocess/errors.rb @@ -1,16 +1,32 @@ +# frozen_string_literal: true + module ChildProcess + # Base class for all errors raised by ChildProcess. class Error < StandardError end + # Raised by {AbstractProcess#poll_for_exit} when the process is still + # alive after the given timeout. class TimeoutError < Error end + # Raised by abstract methods that a concrete subclass is expected to + # override (e.g. {AbstractProcess#io}, {AbstractIO#stdout=}'s + # `check_type`) but hasn't. + # + # @api private class SubclassResponsibility < Error end + # Raised by {ProcessSpawnProcess#launch_process} when + # {AbstractProcess#environment} contains a key or value that can't be + # represented in the child's environment (e.g. it contains a NUL byte, + # or the key contains `=`). class InvalidEnvironmentVariable < Error end + # Raised when the underlying `Process.spawn` call fails, e.g. because + # the executable does not exist or is not executable. class LaunchError < Error end end diff --git a/lib/childprocess/process_spawn_process.rb b/lib/childprocess/process_spawn_process.rb index 11bd3a6..86534ec 100644 --- a/lib/childprocess/process_spawn_process.rb +++ b/lib/childprocess/process_spawn_process.rb @@ -1,21 +1,30 @@ +# frozen_string_literal: true + require_relative 'abstract_process' module ChildProcess + # {AbstractProcess} implementation shared by {Unix::Process} and + # {Windows::Process}, built entirely on Ruby's core `Process.spawn` / + # `Process.waitpid2` / `Process.kill` for maximum portability. `#stop` + # (the only genuinely platform-specific behavior -- which signal(s) to + # try, and in what order) is implemented by the two subclasses. class ProcessSpawnProcess < AbstractProcess + # @return [Integer] the pid of the process, once started attr_reader :pid + # + # @return [Boolean] whether the process has exited + def exited? return true if @exit_code assert_started pid, status = ::Process.waitpid2(@pid, ::Process::WNOHANG | ::Process::WUNTRACED) - pid = nil if pid == 0 # may happen on jruby + pid = nil if pid&.zero? # may happen on jruby; pid is also nil while the process is still running - log(:pid => pid, :status => status) + log(pid: pid, status: status) - if pid - set_exit_code(status) - end + set_exit_code(status) if pid !!pid rescue Errno::ECHILD @@ -23,6 +32,9 @@ def exited? true end + # + # @return [Integer] the exit status of the process, blocking until it exits + def wait assert_started @@ -38,30 +50,39 @@ def wait private def launch_process - environment = {} - @environment.each_pair do |key, value| - key = key.to_s - value = value.nil? ? nil : value.to_s + options = base_spawn_options - if key.include?("\0") || key.include?("=") || value.to_s.include?("\0") - raise InvalidEnvironmentVariable, "#{key.inspect} => #{value.to_s.inspect}" - end - environment[key] = value + if duplex? + reader, writer = ::IO.pipe + options[:in] = reader.fileno + options[writer.fileno] = :close unless ChildProcess.windows? end - options = {} - - options[:out] = io.stdout ? io.stdout.fileno : File::NULL - options[:err] = io.stderr ? io.stderr.fileno : File::NULL + begin + @pid = ::Process.spawn(sanitized_environment, *spawn_args, options) + rescue SystemCallError => e + raise LaunchError, e.message + end if duplex? - reader, writer = ::IO.pipe - options[:in] = reader.fileno - unless ChildProcess.windows? - options[writer.fileno] = :close - end + io._stdin = writer + reader.close end + ::Process.detach(@pid) if detach? + end + + # The base set of options passed to ::Process.spawn: where to send the + # child's stdout/stderr, its process-group behavior, and its cwd. Any + # duplex (stdin pipe) options are added separately by #launch_process, + # since they involve state (the pipe's reader/writer) that's also + # needed after the process has been spawned. + def base_spawn_options + options = { + out: io.stdout ? io.stdout.fileno : File::NULL, + err: io.stderr ? io.stderr.fileno : File::NULL + } + if leader? if ChildProcess.windows? options[:new_pgroup] = true @@ -72,30 +93,37 @@ def launch_process options[:chdir] = @cwd if @cwd - if @args.size == 1 - # When given a single String, Process.spawn would think it should use the shell - # if there is any special character in it. However, ChildProcess should never - # use the shell. So we use the [cmdname, argv0] form to force no shell. - arg = @args[0] - args = [[arg, arg]] - else - args = @args - end + options + end - begin - @pid = ::Process.spawn(environment, *args, options) - rescue SystemCallError => e - raise LaunchError, e.message - end + # Stringifies the configured environment, rejecting anything that + # ::Process.spawn's underlying execve(2) call can't represent. + def sanitized_environment + @environment.each_with_object({}) do |(key, value), environment| + key = key.to_s + value = value&.to_s - if duplex? - io._stdin = writer - reader.close + if key.include?("\0") || key.include?('=') || value.to_s.include?("\0") + raise InvalidEnvironmentVariable, "#{key.inspect} => #{value.to_s.inspect}" + end + + environment[key] = value end + end - ::Process.detach(@pid) if detach? + def spawn_args + return @args unless @args.size == 1 + + # When given a single String, Process.spawn would think it should use the shell + # if there is any special character in it. However, ChildProcess should never + # use the shell. So we use the [cmdname, argv0] form to force no shell. + arg = @args[0] + [[arg, arg]] end + # Records the process's exit status as {AbstractProcess#exit_code}: the + # exit code if it exited normally, or the signal number if it was + # killed by a signal. def set_exit_code(status) @exit_code = status.exitstatus || status.termsig end @@ -108,6 +136,11 @@ def send_kill send_signal 'KILL' end + # Sends `sig` to the process. If it's the {AbstractProcess#leader} of a + # process group, the whole group is targeted: on Unix via a negative + # pid passed to `Process.kill`, on Windows by shelling out to + # `taskkill /T` (there is no direct Ruby API for killing a Windows + # process tree). def send_signal(sig) assert_started diff --git a/lib/childprocess/unix.rb b/lib/childprocess/unix.rb index 700a715..899fae8 100644 --- a/lib/childprocess/unix.rb +++ b/lib/childprocess/unix.rb @@ -1,7 +1,12 @@ +# frozen_string_literal: true + module ChildProcess + # Namespace for the Unix-family (Linux, macOS, BSD, Solaris, AIX, + # Cygwin) process and IO implementations, used whenever + # {ChildProcess.unix?} is `true`. module Unix end end -require_relative "unix/io" -require_relative "unix/process" +require_relative 'unix/io' +require_relative 'unix/process' diff --git a/lib/childprocess/unix/io.rb b/lib/childprocess/unix/io.rb index 739f8c8..bedb559 100644 --- a/lib/childprocess/unix/io.rb +++ b/lib/childprocess/unix/io.rb @@ -1,21 +1,23 @@ +# frozen_string_literal: true + module ChildProcess module Unix + # {AbstractIO} implementation used on Unix-family platforms. Accepts + # any object that responds to `#to_io` and whose `#to_io` returns a + # real `::IO`. class IO < AbstractIO private + # @raise [ArgumentError] if `io` doesn't respond to `#to_io` + # @raise [TypeError] if `io.to_io` doesn't return an `::IO` def check_type(io) - unless io.respond_to? :to_io - raise ArgumentError, "expected #{io.inspect} to respond to :to_io" - end + raise ArgumentError, "expected #{io.inspect} to respond to :to_io" unless io.respond_to? :to_io result = io.to_io - unless result && result.kind_of?(::IO) - raise TypeError, "expected IO, got #{result.inspect}:#{result.class}" - end - end - - end # IO - end # Unix -end # ChildProcess - + return if result.is_a?(::IO) + raise TypeError, "expected IO, got #{result.inspect}:#{result.class}" + end + end + end +end diff --git a/lib/childprocess/unix/process.rb b/lib/childprocess/unix/process.rb index d7afce4..6409afb 100644 --- a/lib/childprocess/unix/process.rb +++ b/lib/childprocess/unix/process.rb @@ -1,12 +1,25 @@ +# frozen_string_literal: true + require_relative '../process_spawn_process' module ChildProcess module Unix + # {AbstractProcess} implementation used on Unix-family platforms. + # {#stop} escalates from `SIGTERM` to `SIGKILL`. class Process < ProcessSpawnProcess + # @return [Unix::IO] def io @io ||= Unix::IO.new end + # + # Sends `SIGTERM`, waits up to `timeout` seconds for the process to + # exit, and sends `SIGKILL` if it hasn't. + # + # @param timeout [Numeric] seconds to wait after `SIGTERM` before sending `SIGKILL` + # @return [Integer, true] the exit status, or `true` if the process + # already died in the race between the timeout and `SIGKILL` + def stop(timeout = 3) assert_started send_term @@ -24,6 +37,6 @@ def stop(timeout = 3) # and send_kill true end - end # Process - end # Unix -end # ChildProcess + end + end +end diff --git a/lib/childprocess/version.rb b/lib/childprocess/version.rb index bb652a5..3c0cf54 100644 --- a/lib/childprocess/version.rb +++ b/lib/childprocess/version.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + module ChildProcess + # The current version of the childprocess gem, following Semantic Versioning. VERSION = '5.1.0' end diff --git a/lib/childprocess/windows.rb b/lib/childprocess/windows.rb index c009c5c..744c7de 100644 --- a/lib/childprocess/windows.rb +++ b/lib/childprocess/windows.rb @@ -1,9 +1,13 @@ -require "rbconfig" +# frozen_string_literal: true + +require 'rbconfig' module ChildProcess + # Namespace for the Windows process and IO implementations, used + # whenever {ChildProcess.windows?} is `true`. module Windows - end # Windows -end # ChildProcess + end +end -require "childprocess/windows/io" -require "childprocess/windows/process" +require 'childprocess/windows/io' +require 'childprocess/windows/process' diff --git a/lib/childprocess/windows/io.rb b/lib/childprocess/windows/io.rb index 6658cc9..f51bd3e 100644 --- a/lib/childprocess/windows/io.rb +++ b/lib/childprocess/windows/io.rb @@ -1,8 +1,13 @@ +# frozen_string_literal: true + module ChildProcess module Windows + # {AbstractIO} implementation used on Windows. Accepts any object with + # a usable `#fileno`, or one whose `#to_io` returns a real `::IO`. class IO < AbstractIO private + # @raise [ArgumentError] if `io` has neither a usable `#fileno` nor a `#to_io` returning an `::IO` def check_type(io) return if has_fileno?(io) return if has_to_io?(io) @@ -10,16 +15,15 @@ def check_type(io) raise ArgumentError, "#{io.inspect}:#{io.class} must have :fileno or :to_io" end + # @return [Boolean] whether `io` responds to `#fileno` with a truthy result def has_fileno?(io) io.respond_to?(:fileno) && io.fileno end + # @return [Boolean] whether `io.to_io` returns a real `::IO` def has_to_io?(io) - io.respond_to?(:to_io) && io.to_io.kind_of?(::IO) + io.respond_to?(:to_io) && io.to_io.is_a?(::IO) end - - end # IO - end # Windows -end # ChildProcess - - + end + end +end diff --git a/lib/childprocess/windows/process.rb b/lib/childprocess/windows/process.rb index 0f6c623..a4b6304 100644 --- a/lib/childprocess/windows/process.rb +++ b/lib/childprocess/windows/process.rb @@ -1,12 +1,27 @@ +# frozen_string_literal: true + require_relative '../process_spawn_process' module ChildProcess module Windows + # {AbstractProcess} implementation used on Windows. Unlike + # {Unix::Process}, {#stop} has no graceful-termination signal to send + # first (Windows has no real equivalent of `SIGTERM`), so it goes + # straight to a forceful kill. class Process < ProcessSpawnProcess + # @return [Windows::IO] def io @io ||= Windows::IO.new end + # + # Forcibly terminates the process and waits up to `timeout` seconds + # for it to exit. + # + # @param timeout [Numeric] seconds to wait for the process to exit after being killed + # @return [Integer, true] the exit status, or `true` if the process + # already died in the race between the timeout and the kill + def stop(timeout = 3) assert_started send_kill @@ -23,6 +38,6 @@ def stop(timeout = 3) # and send_kill true end - end # Process - end # Windows -end # ChildProcess + end + end +end diff --git a/spec/abstract_io_spec.rb b/spec/abstract_io_spec.rb index e677bbe..0d8f353 100644 --- a/spec/abstract_io_spec.rb +++ b/spec/abstract_io_spec.rb @@ -1,12 +1,18 @@ -require File.expand_path('../spec_helper', __FILE__) +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) describe ChildProcess::AbstractIO do - let(:io) { ChildProcess::AbstractIO.new } + let(:io) { described_class.new } it "inherits the parent's IO streams" do io.inherit! - expect(io.stdout).to eq STDOUT - expect(io.stderr).to eq STDERR + expect(io.stdout).to eq $stdout + expect(io.stderr).to eq $stderr + end + + it 'raises SubclassResponsibility when asked to check an IO type' do + expect { io.stdout = $stdout }.to raise_error(ChildProcess::SubclassResponsibility, /check_type/) end end diff --git a/spec/abstract_process_spec.rb b/spec/abstract_process_spec.rb new file mode 100644 index 0000000..628876f --- /dev/null +++ b/spec/abstract_process_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) + +describe ChildProcess::AbstractProcess do + let(:process) { described_class.new('foo') } + + describe '#io' do + it 'raises SubclassResponsibility' do + expect { process.io }.to raise_error(ChildProcess::SubclassResponsibility, /io/) + end + end + + describe '#pid' do + it 'raises SubclassResponsibility' do + expect { process.pid }.to raise_error(ChildProcess::SubclassResponsibility, /pid/) + end + end + + describe '#stop' do + it 'raises SubclassResponsibility' do + expect { process.stop }.to raise_error(ChildProcess::SubclassResponsibility, /stop/) + end + end + + describe '#wait' do + it 'raises SubclassResponsibility' do + expect { process.wait }.to raise_error(ChildProcess::SubclassResponsibility, /wait/) + end + end + + describe '#exited?' do + it 'raises SubclassResponsibility' do + expect { process.exited? }.to raise_error(ChildProcess::SubclassResponsibility, /exited\?/) + end + end + + describe '#start' do + it 'raises SubclassResponsibility, since #start calls the unimplemented #launch_process' do + expect { process.start }.to raise_error(ChildProcess::SubclassResponsibility, /launch_process/) + end + end +end diff --git a/spec/childprocess_spec.rb b/spec/childprocess_spec.rb index 4a949c3..3f87ea1 100644 --- a/spec/childprocess_spec.rb +++ b/spec/childprocess_spec.rb @@ -1,16 +1,12 @@ -# encoding: utf-8 - -require File.expand_path('../spec_helper', __FILE__) +# frozen_string_literal: true +require File.expand_path('spec_helper', __dir__) describe ChildProcess do - - here = File.dirname(__FILE__) - - let(:gemspec) { eval(File.read "#{here}/../childprocess.gemspec") } + let(:gemspec) { Gem::Specification.load(File.expand_path('../childprocess.gemspec', __dir__)) } it 'validates cleanly' do - if Gem::VERSION >= "3.5.0" + if Gem::VERSION >= '3.5.0' expect { gemspec.validate }.not_to output(/warn/i).to_stderr else require 'rubygems/mock_gem_ui' @@ -18,11 +14,11 @@ mock_ui = Gem::MockGemUi.new Gem::DefaultUserInteraction.use_ui(mock_ui) { gemspec.validate } - expect(mock_ui.error).to_not match(/warn/i) + expect(mock_ui.error).not_to match(/warn/i) end end - it "returns self when started" do + it 'returns self when started' do process = sleeping_ruby expect(process.start).to eq process @@ -34,11 +30,11 @@ end it 'raises ArgumentError if given a non-string argument' do - expect { ChildProcess.build(nil, "unlikelytoexist") }.to raise_error(ArgumentError) - expect { ChildProcess.build("foo", 1) }.to raise_error(ArgumentError) + expect { described_class.build(nil, 'unlikelytoexist') }.to raise_error(ArgumentError) + expect { described_class.build('foo', 1) }.to raise_error(ArgumentError) end - it "knows if the process crashed" do + it 'knows if the process crashed' do process = exit_with(1).start process.wait @@ -49,14 +45,14 @@ process = exit_with(0).start process.wait - expect(process).to_not be_crashed + expect(process).not_to be_crashed end - it "can wait for a process to finish" do + it 'can wait for a process to finish' do process = exit_with(0).start return_value = process.wait - expect(process).to_not be_alive + expect(process).not_to be_alive expect(return_value).to eq 0 end @@ -64,22 +60,24 @@ process = exit_with(0).start sleep 0.01 until process.exited? - expect(process.wait).to eql 0 + expect(process.wait).to be 0 end - it "escalates if TERM is ignored" do + it 'escalates if TERM is ignored' do process = ignored('TERM').start process.stop expect(process).to be_exited end - it "accepts a timeout argument to #stop" do + it 'accepts a timeout argument to #stop' do process = sleeping_ruby.start process.stop(exit_timeout) + + expect(process).to be_exited end - it "lets child process inherit the environment of the current process" do - Tempfile.open("env-spec") do |file| + it 'lets child process inherit the environment of the current process' do + Tempfile.open('env-spec') do |file| file.close with_env('INHERITED' => 'yes') do process = write_env(file.path).start @@ -92,14 +90,14 @@ end end - it "can override env vars only for the child process" do - Tempfile.open("env-spec") do |file| + it 'can override env vars only for the child process' do + Tempfile.open('env-spec') do |file| file.close process = write_env(file.path) process.environment['CHILD_ONLY'] = '1' process.start - expect(ENV['CHILD_ONLY']).to be_nil + expect(ENV.fetch('CHILD_ONLY', nil)).to be_nil process.wait @@ -110,7 +108,7 @@ end it 'allows unicode characters in the environment' do - Tempfile.open("env-spec") do |file| + Tempfile.open('env-spec') do |file| file.close process = write_env(file.path) process.environment['FOö'] = 'baör' @@ -124,8 +122,8 @@ end end - it "can set env vars using Symbol keys and values" do - Tempfile.open("env-spec") do |file| + it 'can set env vars using Symbol keys and values' do + Tempfile.open('env-spec') do |file| process = ruby('puts ENV["SYMBOL_KEY"]') process.environment[:SYMBOL_KEY] = :VALUE process.io.stdout = file @@ -135,13 +133,13 @@ end end - it "raises ChildProcess::InvalidEnvironmentVariable for invalid env vars" do + it 'raises ChildProcess::InvalidEnvironmentVariable for invalid env vars' do process = ruby(':OK') process.environment["a\0b"] = '1' expect { process.start }.to raise_error(ChildProcess::InvalidEnvironmentVariable) process = ruby(':OK') - process.environment["A=1"] = '2' + process.environment['A=1'] = '2' expect { process.start }.to raise_error(ChildProcess::InvalidEnvironmentVariable) process = ruby(':OK') @@ -150,7 +148,7 @@ end it "inherits the parent's env vars also when some are overridden" do - Tempfile.open("env-spec") do |file| + Tempfile.open('env-spec') do |file| file.close with_env('INHERITED' => 'yes', 'CHILD_ONLY' => 'no') do process = write_env(file.path) @@ -168,8 +166,8 @@ end end - it "can unset env vars" do - Tempfile.open("env-spec") do |file| + it 'can unset env vars' do + Tempfile.open('env-spec') do |file| file.close ENV['CHILDPROCESS_UNSET'] = '1' process = write_env(file.path) @@ -180,7 +178,7 @@ file.open child_env = eval rewind_and_read(file) - expect(child_env).to_not have_key('CHILDPROCESS_UNSET') + expect(child_env).not_to have_key('CHILDPROCESS_UNSET') end end @@ -195,15 +193,14 @@ file.open child_env = eval rewind_and_read(file) - expect(child_env).to_not have_key('CHILDPROCESS_UNSET') + expect(child_env).not_to have_key('CHILDPROCESS_UNSET') end end + it 'passes arguments to the child' do + args = %w[foo bar] - it "passes arguments to the child" do - args = ["foo", "bar"] - - Tempfile.open("argv-spec") do |file| + Tempfile.open('argv-spec') do |file| process = write_argv(file.path, *args).start process.wait @@ -211,20 +208,22 @@ end end - it "lets a detached child live on" do + it 'lets a detached child live on' do p_pid = nil c_pid = nil Tempfile.open('grandparent_out') do |gp_file| - # Create a parent and detached child process that will spit out their PID. Make sure that the child process lasts longer than the parent. + # Create a parent and detached child process that will spit out their + # PID. Make sure that the child process lasts longer than the parent. + # rubocop:disable Layout/LineLength p_process = ruby("$: << 'lib'; require 'childprocess' ; c_process = ChildProcess.build('ruby', '-e', 'puts \\\"Child PID: \#{Process.pid}\\\" ; sleep 5') ; c_process.io.inherit! ; c_process.detach = true ; c_process.start ; puts \"Child PID: \#{c_process.pid}\" ; puts \"Parent PID: \#{Process.pid}\"") + # rubocop:enable Layout/LineLength p_process.io.stdout = p_process.io.stderr = gp_file # Let the parent process die p_process.start p_process.wait - # Gather parent and child PIDs pids = rewind_and_read(gp_file).split("\n") pids.collect! { |pid| pid[/\d+/].to_i } @@ -232,13 +231,13 @@ end # Check that the parent process has dies but the child process is still alive - expect(alive?(p_pid)).to_not be true + expect(alive?(p_pid)).not_to be true expect(alive?(c_pid)).to be true end - it "preserves Dir.pwd in the child" do - Tempfile.open("dir-spec-out") do |file| - process = ruby("print Dir.pwd") + it 'preserves Dir.pwd in the child' do + Tempfile.open('dir-spec-out') do |file| + process = ruby('print Dir.pwd') process.io.stdout = process.io.stderr = file expected_dir = nil @@ -253,10 +252,10 @@ end end - it "can handle whitespace, special characters and quotes in arguments" do - args = ["foo bar", 'foo\bar', "'i-am-quoted'", '"i am double quoted"'] + it 'can handle whitespace, special characters and quotes in arguments' do + args = ['foo bar', 'foo\bar', "'i-am-quoted'", '"i am double quoted"'] - Tempfile.open("argv-spec") do |file| + Tempfile.open('argv-spec') do |file| process = write_argv(file.path, *args).start process.wait @@ -273,15 +272,15 @@ end end - it "times out when polling for exit" do + it 'times out when polling for exit' do process = sleeping_ruby.start expect { process.poll_for_exit(0.1) }.to raise_error(ChildProcess::TimeoutError) end - it "can change working directory" do - process = ruby "print Dir.pwd" + it 'can change working directory' do + process = ruby 'print Dir.pwd' - with_tmpdir { |dir| + with_tmpdir do |dir| process.cwd = dir orig_pwd = Dir.pwd @@ -296,7 +295,7 @@ end expect(Dir.pwd).to eq orig_pwd - } + end end it 'kills the full process tree' do @@ -306,13 +305,13 @@ process.start pid = wait_until(30) do - Integer(rewind_and_read(file)) rescue nil + Integer(rewind_and_read(file), exception: false) end process.stop expect(process).to be_exited - wait_until(3) { expect(alive?(pid)).to eql(false) } + wait_until(3) { expect(alive?(pid)).to be(false) } end end @@ -323,11 +322,11 @@ threads << Thread.new { sleeping_ruby(1).start.wait } threads << Thread.new(time) { expect(Time.now - time).to be < 0.5 } - threads.each { |t| t.join } + threads.each(&:join) end it 'can check if a detached child is alive' do - proc = ruby_process("-e", "sleep") + proc = ruby_process('-e', 'sleep') proc.detach = true proc.start @@ -339,72 +338,244 @@ end describe 'OS detection' do - before(:all) do # Save off original OS so that it can be restored later @original_host_os = RbConfig::CONFIG['host_os'] end - after(:each) do + after do # Restore things to the real OS instead of the fake test OS RbConfig::CONFIG['host_os'] = @original_host_os - ChildProcess.instance_variable_set(:@os, nil) + described_class.instance_variable_set(:@os, nil) end + def fake_host_os(pattern) + RbConfig::CONFIG['host_os'] = pattern + ChildProcess.instance_variable_set(:@os, nil) + end - # TODO: add tests for other OSs - context 'on a BSD system' do - - let(:bsd_patterns) { ['bsd', 'dragonfly'] } + context 'when on a BSD system' do + let(:bsd_patterns) { %w[bsd dragonfly] } it 'correctly identifies BSD systems' do bsd_patterns.each do |pattern| - RbConfig::CONFIG['host_os'] = pattern - ChildProcess.instance_variable_set(:@os, nil) + fake_host_os(pattern) - expect(ChildProcess.os).to eq(:bsd) + expect(described_class.os).to eq(:bsd) end end + end + + context 'when on a Linux system' do + it 'correctly identifies Linux systems' do + fake_host_os('linux-gnu') + + expect(described_class.os).to eq(:linux) + end + end + context 'when on a Mac OS X system' do + it 'correctly identifies Darwin/Mac OS X systems' do + ['darwin18', 'Mac OS X'].each do |pattern| + fake_host_os(pattern) + + expect(described_class.os).to eq(:macosx) + end + end end + context 'when on a Windows system' do + it 'correctly identifies Windows systems' do + %w[mswin32 msys mingw32].each do |pattern| + fake_host_os(pattern) + + expect(described_class.os).to eq(:windows) + end + end + end + + context 'when on a Cygwin system' do + it 'correctly identifies Cygwin systems' do + fake_host_os('cygwin') + + expect(described_class.os).to eq(:cygwin) + end + end + + context 'when on a Solaris system' do + it 'correctly identifies Solaris/SunOS systems' do + %w[solaris2 sunos4].each do |pattern| + fake_host_os(pattern) + + expect(described_class.os).to eq(:solaris) + end + end + end + + context 'when on an AIX system' do + it 'correctly identifies AIX systems' do + fake_host_os('aix') + + expect(described_class.os).to eq(:aix) + end + end + + context 'when on an unrecognized system' do + it 'raises an Error' do + fake_host_os('amiga-os') + + expect { described_class.os }.to raise_error(ChildProcess::Error, /unknown os/) + end + end + + context 'when FAKE_WINDOWS is set' do + it 'reports :windows regardless of the real host OS' do + with_env('FAKE_WINDOWS' => 'true') do + expect(described_class.os).to eq(:windows) + end + end + end + end + + describe '.new/.build' do + after { described_class.instance_variable_set(:@platform_name, nil) } + + %i[macosx linux solaris bsd cygwin aix].each do |unix_like_os| + it "builds a Unix::Process on #{unix_like_os}" do + allow(described_class).to receive(:os).and_return(unix_like_os) + + expect(described_class.new('foo')).to be_a(ChildProcess::Unix::Process) + end + end + + it 'builds a Windows::Process on windows' do + allow(described_class).to receive(:os).and_return(:windows) + + expect(described_class.new('foo')).to be_a(ChildProcess::Windows::Process) + end + + it 'is aliased as .build' do + expect(described_class.method(:build)).to eq(described_class.method(:new)) + end + + it 'raises an Error for an unsupported platform' do + allow(described_class).to receive(:os).and_return(:some_unsupported_os) + + expect { described_class.new('foo') }.to raise_error(ChildProcess::Error, /unsupported platform/) + end + end + + describe '.platform' do + it 'returns the detected OS' do + expect(described_class.platform).to eq(described_class.os) + end + end + + describe '.platform_name' do + after { described_class.instance_variable_set(:@platform_name, nil) } + + it 'combines the arch and the os' do + expect(described_class.platform_name).to eq("#{described_class.arch}-#{described_class.os}") + end + end + + describe '.linux?' do + it 'reflects whether the OS is Linux' do + allow(described_class).to receive(:os).and_return(:linux) + expect(described_class.linux?).to be true + + allow(described_class).to receive(:os).and_return(:macosx) + expect(described_class.linux?).to be false + end + end + + describe '.jruby?' do + it 'reflects whether the current Ruby engine is JRuby' do + expect(described_class.jruby?).to eq(RUBY_ENGINE == 'jruby') + end + end + + describe '.unix?/.windows?' do + it 'are complementary' do + allow(described_class).to receive(:os).and_return(:windows) + expect(described_class.windows?).to be true + expect(described_class.unix?).to be false + + allow(described_class).to receive(:os).and_return(:linux) + expect(described_class.windows?).to be false + expect(described_class.unix?).to be true + end + end + + describe 'posix_spawn' do + after do + described_class.posix_spawn = false + ENV.delete('CHILDPROCESS_POSIX_SPAWN') + end + + it '.posix_spawn? always returns false since ChildProcess 5' do + expect(described_class.posix_spawn?).to be false + end + + it '.posix_spawn= toggles whether posix_spawn was chosen explicitly' do + expect(described_class.posix_spawn_chosen_explicitly?).to be false + + described_class.posix_spawn = true + + expect(described_class.posix_spawn_chosen_explicitly?).to be true + end + + it '.posix_spawn_chosen_explicitly? also honors the CHILDPROCESS_POSIX_SPAWN env var' do + ENV['CHILDPROCESS_POSIX_SPAWN'] = 'true' + + expect(described_class.posix_spawn_chosen_explicitly?).to be true + end + end + + describe '.close_on_exec' do + it 'raises an Error when the given object has no close_on_exec=' do + expect do + described_class.close_on_exec(Object.new) + end.to raise_error(ChildProcess::Error, /not sure how to set close-on-exec/) + end + end + + describe 'arch detection internals' do + it '.send(:is_64_bit?) reflects the native pointer size of this Ruby' do + expect(described_class.send(:is_64_bit?)).to eq(1.size == 8) + end end it 'has a logger' do - expect(ChildProcess).to respond_to(:logger) + expect(described_class).to respond_to(:logger) end it 'can change its logger' do - expect(ChildProcess).to respond_to(:logger=) + expect(described_class).to respond_to(:logger=) - original_logger = ChildProcess.logger + original_logger = described_class.logger begin - ChildProcess.logger = :some_other_logger - expect(ChildProcess.logger).to eq(:some_other_logger) + described_class.logger = :some_other_logger + expect(described_class.logger).to eq(:some_other_logger) ensure - ChildProcess.logger = original_logger + described_class.logger = original_logger end end - describe 'logger' do - - before(:each) do - ChildProcess.logger = logger + before do + described_class.logger = logger end after(:all) do - ChildProcess.logger = nil + described_class.logger = nil end - context 'with the default logger' do - let(:logger) { nil } - it 'logs at INFO level by default' do - expect(ChildProcess.logger.level).to eq(Logger::INFO) + expect(described_class.logger.level).to eq(Logger::INFO) end it 'logs at DEBUG level by default if $DEBUG is on' do @@ -413,34 +584,30 @@ begin $DEBUG = true - expect(ChildProcess.logger.level).to eq(Logger::DEBUG) + expect(described_class.logger.level).to eq(Logger::DEBUG) ensure $DEBUG = original_debug end end - it "logs to stderr by default" do + it 'logs to stderr by default' do cap = capture_std { generate_log_messages } expect(cap.stdout).to be_empty - expect(cap.stderr).to_not be_empty + expect(cap.stderr).not_to be_empty end - end context 'with a custom logger' do - let(:logger) { Logger.new($stdout) } - it "logs to configured logger" do + it 'logs to configured logger' do cap = capture_std { generate_log_messages } - expect(cap.stdout).to_not be_empty + expect(cap.stdout).not_to be_empty expect(cap.stderr).to be_empty end - end - end describe '#started?' do @@ -459,13 +626,11 @@ end context 'when finished' do - before(:each) { process.wait } + before { process.wait } let(:process) { sleeping_ruby(0).start } it { is_expected.to be true } end - end - end diff --git a/spec/io_spec.rb b/spec/io_spec.rb index 0c16ee9..41cb239 100644 --- a/spec/io_spec.rb +++ b/spec/io_spec.rb @@ -1,17 +1,20 @@ -require File.expand_path('../spec_helper', __FILE__) +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) describe ChildProcess do - it "can run even when $stdout is a StringIO" do - begin - stdout = $stdout - $stdout = StringIO.new - expect { sleeping_ruby.start }.to_not raise_error - ensure - $stdout = stdout - end + # rubocop:disable RSpec/ExpectOutput -- not asserting on output, just that + # replacing the global $stdout doesn't break spawning + it 'can run even when $stdout is a StringIO' do + stdout = $stdout + $stdout = StringIO.new + expect { sleeping_ruby.start }.not_to raise_error + ensure + $stdout = stdout end + # rubocop:enable RSpec/ExpectOutput - it "can redirect stdout, stderr" do + it 'can redirect stdout, stderr' do process = ruby(<<-CODE) [STDOUT, STDERR].each_with_index do |io, idx| io.sync = true @@ -19,8 +22,8 @@ end CODE - out = Tempfile.new("stdout-spec") - err = Tempfile.new("stderr-spec") + out = Tempfile.new('stdout-spec') + err = Tempfile.new('stderr-spec') begin process.io.stdout = out @@ -38,7 +41,7 @@ end end - it "can redirect stdout only" do + it 'can redirect stdout only' do process = ruby(<<-CODE) [STDOUT, STDERR].each_with_index do |io, idx| io.sync = true @@ -46,7 +49,7 @@ end CODE - out = Tempfile.new("stdout-spec") + out = Tempfile.new('stdout-spec') begin process.io.stdout = out @@ -60,10 +63,10 @@ end end - it "pumps all output" do + it 'pumps all output' do process = echo - out = Tempfile.new("pump") + out = Tempfile.new('pump') begin process.io.stdout = out @@ -77,10 +80,10 @@ end end - it "can write to stdin if duplex = true" do + it 'can write to stdin if duplex = true' do process = cat - out = Tempfile.new("duplex") + out = Tempfile.new('duplex') out.sync = true begin @@ -89,7 +92,7 @@ process.duplex = true process.start - process.io.stdin.puts "hello world" + process.io.stdin.puts 'hello world' process.io.stdin.close process.poll_for_exit(exit_timeout) @@ -100,13 +103,15 @@ end end - it "can write to stdin interactively if duplex = true" do + it 'can write to stdin interactively if duplex = true' do process = cat - out = Tempfile.new("duplex") + out = Tempfile.new('duplex') out.sync = true - out_receiver = File.open(out.path, "rb") + # out_receiver intentionally stays open for the whole example (closed in + # the ensure block below) so it can be read from incrementally below. + out_receiver = File.open(out.path, 'rb') # rubocop:disable Style/FileOpen begin process.io.stdout = out process.io.stderr = out @@ -116,19 +121,19 @@ stdin = process.io.stdin - stdin.puts "hello" + stdin.puts 'hello' stdin.flush wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\n\z/m) } - stdin.putc "n" + stdin.putc 'n' stdin.flush wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nn\z/m) } - stdin.print "e" + stdin.print 'e' stdin.flush wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nne\z/m) } - stdin.printf "w" + stdin.printf 'w' stdin.flush wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nnew\z/m) } @@ -151,7 +156,7 @@ # http://travis-ci.org/#!/enkessler/childprocess/jobs/487331 # - it "works with pipes" do + it 'works with pipes' do process = ruby(<<-CODE) STDOUT.print "stdout" STDERR.print "stderr" @@ -181,10 +186,10 @@ expect([out, err]).to eq %w[stdout stderr] end - it "can set close-on-exec when IO is inherited" do + it 'can set close-on-exec when IO is inherited' do port = random_free_port - server = TCPServer.new("127.0.0.1", port) - ChildProcess.close_on_exec server + server = TCPServer.new('127.0.0.1', port) + described_class.close_on_exec server process = sleeping_ruby process.io.inherit! @@ -192,15 +197,15 @@ process.start server.close - wait_until { can_bind? "127.0.0.1", port } + wait_until { expect(can_bind?('127.0.0.1', port)).to be true } end - it "handles long output" do + it 'handles long output' do process = ruby <<-CODE print 'a'*3000 CODE - out = Tempfile.new("long-output") + out = Tempfile.new('long-output') out.sync = true begin @@ -215,7 +220,7 @@ end end - it 'should not inherit stdout and stderr by default' do + it 'does not inherit stdout and stderr by default' do cap = capture_std do process = echo process.start diff --git a/spec/pid_behavior.rb b/spec/pid_behavior.rb index 5bcb15d..ee35e3c 100644 --- a/spec/pid_behavior.rb +++ b/spec/pid_behavior.rb @@ -1,8 +1,10 @@ -require File.expand_path('../spec_helper', __FILE__) +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) shared_examples_for "a platform that provides the child's pid" do it "knows the child's pid" do - Tempfile.open("pid-spec") do |file| + Tempfile.open('pid-spec') do |file| process = write_pid(file.path).start process.wait diff --git a/spec/platform_detection_spec.rb b/spec/platform_detection_spec.rb index 4af67e2..8d3a00c 100644 --- a/spec/platform_detection_spec.rb +++ b/spec/platform_detection_spec.rb @@ -1,22 +1,23 @@ -require File.expand_path('../spec_helper', __FILE__) +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) # Q: Should platform detection concern be extracted from ChildProcess? describe ChildProcess do - - describe ".arch" do + describe '.arch' do subject { described_class.arch } - before(:each) { described_class.instance_variable_set(:@arch, nil) } + before { described_class.instance_variable_set(:@arch, nil) } - after(:each) { described_class.instance_variable_set(:@arch, nil) } + after { described_class.instance_variable_set(:@arch, nil) } shared_examples 'expected_arch_for_host_cpu' do |host_cpu, expected_arch| context "when host_cpu is '#{host_cpu}'" do - before :each do - allow(RbConfig::CONFIG). - to receive(:[]). - with('host_cpu'). - and_return(expected_arch) + before do + allow(RbConfig::CONFIG) + .to receive(:[]) + .with('host_cpu') + .and_return(host_cpu) end it { is_expected.to eq expected_arch } @@ -25,7 +26,7 @@ # Normal cases: not macosx - depends only on host_cpu context "when os is *not* 'macosx'" do - before :each do + before do allow(described_class).to receive(:os).and_return(:not_macosx) end @@ -38,7 +39,7 @@ { host_cpu: 'x86_64', expected_arch: 'x86_64' }, { host_cpu: 'ppc', expected_arch: 'powerpc' }, { host_cpu: 'powerpc', expected_arch: 'powerpc' }, - { host_cpu: 'unknown', expected_arch: 'unknown' }, + { host_cpu: 'unknown', expected_arch: 'unknown' } ].each do |args| include_context 'expected_arch_for_host_cpu', args.values end @@ -46,17 +47,17 @@ # Special cases: macosx - when host_cpu is i686, have to re-check context "when os is 'macosx'" do - before :each do + before do allow(described_class).to receive(:os).and_return(:macosx) end - context "when host_cpu is 'i686' " do - shared_examples 'expected_arch_on_macosx_i686' do |is_64, expected_arch| - context "when Ruby is #{is_64 ? 64 : 32}-bit" do - before :each do - allow(described_class). - to receive(:is_64_bit?). - and_return(is_64) + context "when host_cpu is 'i686'" do + shared_context 'when checking arch on macosx i686' do |sixty_four_bit, expected_arch| + context "when Ruby is #{sixty_four_bit ? 64 : 32}-bit" do + before do + allow(described_class) + .to receive(:is_64_bit?) + .and_return(sixty_four_bit) end include_context 'expected_arch_for_host_cpu', 'i686', expected_arch @@ -64,10 +65,10 @@ end [ - { is_64: true, expected_arch: 'x86_64' }, - { is_64: false, expected_arch: 'i386' } + { sixty_four_bit: true, expected_arch: 'x86_64' }, + { sixty_four_bit: false, expected_arch: 'i386' } ].each do |args| - include_context 'expected_arch_on_macosx_i686', args.values + include_context 'when checking arch on macosx i686', args.values end end @@ -76,11 +77,10 @@ { host_cpu: 'x86_64', expected_arch: 'x86_64' }, { host_cpu: 'ppc', expected_arch: 'powerpc' }, { host_cpu: 'powerpc', expected_arch: 'powerpc' }, - { host_cpu: 'unknown', expected_arch: 'unknown' }, + { host_cpu: 'unknown', expected_arch: 'unknown' } ].each do |args| include_context 'expected_arch_for_host_cpu', args.values end end end - end diff --git a/spec/process_spawn_process_spec.rb b/spec/process_spawn_process_spec.rb new file mode 100644 index 0000000..914e598 --- /dev/null +++ b/spec/process_spawn_process_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) + +describe ChildProcess::ProcessSpawnProcess do + # These specs stub out ::Process.spawn entirely, so they build the process + # directly (rather than via helpers like #sleeping_ruby) to avoid the + # global spec_helper `after(:each)` hook trying to stop/reap a fake pid + # once the example's stubs have been torn down. + let(:process) { ChildProcess.build('ruby', '-e', 'sleep') } + + describe '#exited?' do + it 'treats Errno::ECHILD from waitpid2 as the process having exited (e.g. a detached process)' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.start + + allow(Process).to receive(:waitpid2).and_raise(Errno::ECHILD) + + expect(process.exited?).to be true + end + end + + describe '#launch_process' do + it 'requests a new process group via :new_pgroup when leader on Windows' do + process.leader = true + allow(ChildProcess).to receive(:windows?).and_return(true) + + spawn_options = nil + allow(Process).to receive(:spawn) do |*spawn_args| + spawn_options = spawn_args.last + 'fakepid' + end + + process.start + + expect(spawn_options[:new_pgroup]).to be true + end + end + + describe '#send_signal' do + it 'shells out to taskkill when signaling the leader of a process group on Windows' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.leader = true + process.start + + allow(ChildProcess).to receive(:unix?).and_return(false) + allow(process).to receive(:`).and_return("SUCCESS\n") + + process.send(:send_term) + + expect(process).to have_received(:`).with('taskkill /F /T /PID fakepid') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b136d9f..f8e7882 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,12 +1,26 @@ +# frozen_string_literal: true + +require 'English' $LOAD_PATH.unshift(File.dirname(__FILE__)) $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) unless defined?(JRUBY_VERSION) - require 'coveralls' - Coveralls.wear! + require 'simplecov' + SimpleCov.start do + add_filter '/spec/' + end end require 'childprocess' + +# Both platform implementations are loaded regardless of the OS actually +# running the specs, so that specs can reference `ChildProcess::Unix::*` and +# `ChildProcess::Windows::*` directly (e.g. to assert which one +# `ChildProcess.build` picks for a given platform). Only the implementation +# matching the real OS is exercised at runtime. +require 'childprocess/unix' +require 'childprocess/windows' + require 'rspec' require 'tempfile' require 'socket' @@ -16,24 +30,24 @@ module ChildProcessSpecHelper RUBY = defined?(Gem) ? Gem.ruby : 'ruby' CapturedOutput = Struct.new(:stdout, :stderr) - def ruby_process(*args) - @process = ChildProcess.build(RUBY , *args) + def ruby_process(*) + @process = ChildProcess.build(RUBY, *) end - def windows_process(*args) - @process = ChildProcess.build("powershell", *args) + def windows_process(*) + @process = ChildProcess.build('powershell', *) end def sleeping_ruby(seconds = nil) if seconds - ruby_process("-e", "sleep #{seconds}") + ruby_process('-e', "sleep #{seconds}") else - ruby_process("-e", "sleep") + ruby_process('-e', 'sleep') end end def invalid_process - @process = ChildProcess.build("unlikelytoexist") + @process = ChildProcess.build('unlikelytoexist') end def ignored(signal) @@ -47,7 +61,7 @@ def ignored(signal) def write_env(path) if ChildProcess.os == :windows - ps_env_file_path = File.expand_path(File.dirname(__FILE__)) + ps_env_file_path = __dir__ args = ['-File', "#{ps_env_file_path}/get_env.ps1", path] windows_process(*args) else @@ -58,12 +72,12 @@ def write_env(path) end end - def write_argv(path, *args) + def write_argv(path, *) code = <<-RUBY File.open(#{path.inspect}, "w") { |f| f << ARGV.inspect } RUBY - ruby_process(tmp_script(code), *args) + ruby_process(tmp_script(code), *) end def write_pid(path) @@ -87,7 +101,7 @@ def exit_with(exit_code) end def with_env(hash) - hash.each { |k,v| ENV[k] = v } + hash.each { |k, v| ENV[k] = v } begin yield ensure @@ -97,11 +111,11 @@ def with_env(hash) def tmp_script(code) # use an ivar to avoid GC - @tf = Tempfile.new("childprocess-temp") + @tf = Tempfile.new('childprocess-temp') @tf << code @tf.close - puts code if $DEBUG + puts code if $DEBUG # rubocop:disable RSpec/Output -- opt-in debug helper, not accidental debug output @tf.path end @@ -113,7 +127,7 @@ def cat IO.copy_stream(STDIN, STDOUT) CODE else - ChildProcess.build("cat") + ChildProcess.build('cat') end end @@ -126,7 +140,7 @@ def echo puts "hello" CODE else - ChildProcess.build("echo", "hello") + ChildProcess.build('echo', 'hello') end end @@ -134,15 +148,15 @@ def ruby(code) ruby_process(tmp_script(code)) end - def with_executable_at(path, &blk) + def with_executable_at(path, &) if ChildProcess.os == :windows - path << ".cmd" + path << '.cmd' content = "#{RUBY} -e 'sleep 10' \n @echo foo" else content = "#!/bin/sh\nsleep 10\necho foo" end - File.open(path, 'w', 0744) { |io| io << content } + File.open(path, 'w', 0o744) { |io| io << content } proc = ChildProcess.build(path) begin @@ -165,8 +179,8 @@ def random_free_port port end - def with_tmpdir(&blk) - name = "#{Time.now.strftime("%Y%m%d")}-#{$$}-#{rand(0x100000000).to_s(36)}" + def with_tmpdir(&) + name = "#{Time.now.strftime('%Y%m%d')}-#{$PROCESS_ID}-#{rand(0x100000000).to_s(36)}" FileUtils.mkdir_p(name) begin @@ -176,7 +190,7 @@ def with_tmpdir(&blk) end end - def wait_until(timeout = 10, &blk) + def wait_until(timeout = 10, &) end_time = Time.now + timeout last_exception = nil @@ -184,8 +198,8 @@ def wait_until(timeout = 10, &blk) begin result = yield return result if result - rescue RSpec::Expectations::ExpectationNotMetError => ex - last_exception = ex + rescue RSpec::Expectations::ExpectationNotMetError => e + last_exception = e end sleep 0.01 @@ -200,7 +214,7 @@ def wait_until(timeout = 10, &blk) def can_bind?(host, port) TCPServer.new(host, port).close true - rescue + rescue StandardError false end @@ -216,23 +230,23 @@ def alive?(pid) end def capture_std - orig_out = STDOUT.clone - orig_err = STDERR.clone + orig_out = $stdout.clone + orig_err = $stderr.clone out = Tempfile.new 'captured-stdout' err = Tempfile.new 'captured-stderr' out.sync = true err.sync = true - STDOUT.reopen out - STDERR.reopen err + $stdout.reopen out + $stderr.reopen err yield CapturedOutput.new rewind_and_read(out), rewind_and_read(err) ensure - STDOUT.reopen orig_out - STDERR.reopen orig_err + $stdout.reopen orig_out + $stderr.reopen orig_err end def generate_log_messages @@ -242,14 +256,13 @@ def generate_log_messages process.wait process.poll_for_exit(0.1) end - -end # ChildProcessSpecHelper +end Thread.abort_on_exception = true RSpec.configure do |c| c.include(ChildProcessSpecHelper) - c.after(:each) { + c.after do defined?(@process) && @process.alive? && @process.stop - } + end end diff --git a/spec/unix_spec.rb b/spec/unix_spec.rb index fe5b61b..c68aeab 100644 --- a/spec/unix_spec.rb +++ b/spec/unix_spec.rb @@ -1,12 +1,14 @@ -require File.expand_path('../spec_helper', __FILE__) -require "pid_behavior" +# frozen_string_literal: true + +require File.expand_path('spec_helper', __dir__) +require 'pid_behavior' if ChildProcess.unix? describe ChildProcess::Unix::Process do it_behaves_like "a platform that provides the child's pid" - it "handles ECHILD race condition where process dies between timeout and KILL" do + it 'handles ECHILD race condition where process dies between timeout and KILL' do process = sleeping_ruby allow(Process).to receive(:spawn).and_return('fakepid') @@ -20,7 +22,7 @@ allow(process).to receive(:alive?).and_return(false) end - it "handles ESRCH race condition where process dies between timeout and KILL" do + it 'handles ESRCH race condition where process dies between timeout and KILL' do process = sleeping_ruby allow(Process).to receive(:spawn).and_return('fakepid') @@ -36,15 +38,15 @@ end describe ChildProcess::Unix::IO do - let(:io) { ChildProcess::Unix::IO.new } + let(:io) { described_class.new } - it "raises an ArgumentError if given IO does not respond to :to_io" do + it 'raises an ArgumentError if given IO does not respond to :to_io' do expect { io.stdout = nil }.to raise_error(ArgumentError, /to respond to :to_io/) end - it "raises a TypeError if #to_io does not return an IO" do + it 'raises a TypeError if #to_io does not return an IO' do fake_io = Object.new - def fake_io.to_io() StringIO.new end + def fake_io.to_io = StringIO.new expect { io.stdout = fake_io }.to raise_error(TypeError, /expected IO, got/) end diff --git a/spec/windows_spec.rb b/spec/windows_spec.rb index e1688bd..9799b4f 100644 --- a/spec/windows_spec.rb +++ b/spec/windows_spec.rb @@ -1,23 +1,93 @@ -require File.expand_path('../spec_helper', __FILE__) -require "pid_behavior" +# frozen_string_literal: true -if ChildProcess.windows? - describe ChildProcess::Windows::Process do - it_behaves_like "a platform that provides the child's pid" +require File.expand_path('spec_helper', __dir__) +require 'pid_behavior' + +# The behavior of ChildProcess::Windows::Process and ChildProcess::Windows::IO +# is plain Ruby with no OS-specific system calls, so most of it can be +# exercised (with ::Process.spawn stubbed out) on any platform, not just +# Windows. Only the shared pid_behavior integration spec actually needs to +# run a real process, so it's gated on ChildProcess.windows?. +describe ChildProcess::Windows::Process do + it_behaves_like "a platform that provides the child's pid" if ChildProcess.windows? + + let(:process) { described_class.new('ruby', '-e', 'sleep') } + + describe '#io' do + it 'returns a memoized Windows::IO' do + expect(process.io).to be_a(ChildProcess::Windows::IO) + expect(process.io).to equal(process.io) + end + end + + describe '#stop' do + it 'sends KILL and waits for the process to exit' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.start + + allow(process).to receive(:send_kill) + allow(process).to receive(:poll_for_exit).and_return(0) + + expect(process.stop).to eq(0) + expect(process).to have_received(:send_kill) + end + + it 'falls back to #wait if polling for exit times out after KILL' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.start + + allow(process).to receive(:send_kill) + allow(process).to receive(:poll_for_exit).and_raise(ChildProcess::TimeoutError) + allow(process).to receive(:wait).and_return(1) + + expect(process.stop).to eq(1) + end + + it 'handles ECHILD race condition where process dies between timeout and KILL' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.start + + allow(process).to receive(:send_kill).and_raise(Errno::ECHILD.new) + + expect { process.stop }.not_to raise_error + end + + it 'handles ESRCH race condition where process dies between timeout and KILL' do + allow(Process).to receive(:spawn).and_return('fakepid') + process.start + + allow(process).to receive(:send_kill).and_raise(Errno::ESRCH.new) + + expect { process.stop }.not_to raise_error + end end describe ChildProcess::Windows::IO do - let(:io) { ChildProcess::Windows::IO.new } + let(:io) { described_class.new } - it "raises an ArgumentError if given IO does not respond to :fileno" do + it 'raises an ArgumentError if given IO does not respond to :fileno' do expect { io.stdout = nil }.to raise_error(ArgumentError, /must have :fileno or :to_io/) end - it "raises an ArgumentError if the #to_io does not return an IO " do + it 'raises an ArgumentError if the #to_io does not return an IO' do fake_io = Object.new - def fake_io.to_io() StringIO.new end + def fake_io.to_io = StringIO.new expect { io.stdout = fake_io }.to raise_error(ArgumentError, /must have :fileno or :to_io/) end + + it 'accepts an object that responds to :fileno' do + fake_io = Object.new + def fake_io.fileno = 5 + + expect { io.stdout = fake_io }.not_to raise_error + end + + it "accepts an object that doesn't respond to :fileno but whose #to_io returns an IO" do + fake_io = Object.new + def fake_io.to_io = $stdout + + expect { io.stdout = fake_io }.not_to raise_error + end end end