├── .rspec ├── lib ├── childprocess │ ├── version.rb │ ├── unix.rb │ ├── windows.rb │ ├── errors.rb │ ├── unix │ │ ├── io.rb │ │ └── process.rb │ ├── windows │ │ ├── io.rb │ │ └── process.rb │ ├── abstract_io.rb │ ├── process_spawn_process.rb │ └── abstract_process.rb └── childprocess.rb ├── .document ├── Gemfile ├── .gitignore ├── spec ├── abstract_io_spec.rb ├── get_env.ps1 ├── pid_behavior.rb ├── windows_spec.rb ├── unix_spec.rb ├── platform_detection_spec.rb ├── io_spec.rb ├── spec_helper.rb └── childprocess_spec.rb ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── childprocess.gemspec ├── Rakefile ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /lib/childprocess/version.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | VERSION = '5.0.0' 3 | end 4 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | - 6 | LICENSE 7 | -------------------------------------------------------------------------------- /lib/childprocess/unix.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | module Unix 3 | end 4 | end 5 | 6 | require_relative "unix/io" 7 | require_relative "unix/process" 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | # Specify your gem's dependencies in child_process.gemspec 4 | gemspec 5 | 6 | # Used for local development/testing only 7 | gem 'rake' 8 | -------------------------------------------------------------------------------- /lib/childprocess/windows.rb: -------------------------------------------------------------------------------- 1 | require "rbconfig" 2 | 3 | module ChildProcess 4 | module Windows 5 | end # Windows 6 | end # ChildProcess 7 | 8 | require "childprocess/windows/io" 9 | require "childprocess/windows/process" 10 | -------------------------------------------------------------------------------- /lib/childprocess/errors.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | class Error < StandardError 3 | end 4 | 5 | class TimeoutError < Error 6 | end 7 | 8 | class SubclassResponsibility < Error 9 | end 10 | 11 | class InvalidEnvironmentVariable < Error 12 | end 13 | 14 | class LaunchError < Error 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## RubyMine 17 | .idea/* 18 | 19 | ## PROJECT::GENERAL 20 | coverage 21 | rdoc 22 | pkg 23 | .rbx 24 | Gemfile.lock 25 | .ruby-version 26 | .bundle 27 | 28 | ## PROJECT::SPECIFIC 29 | -------------------------------------------------------------------------------- /spec/abstract_io_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | describe ChildProcess::AbstractIO do 4 | let(:io) { ChildProcess::AbstractIO.new } 5 | 6 | it "inherits the parent's IO streams" do 7 | io.inherit! 8 | 9 | expect(io.stdout).to eq STDOUT 10 | expect(io.stderr).to eq STDERR 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/get_env.ps1: -------------------------------------------------------------------------------- 1 | param($p1) 2 | $env_list = Get-ChildItem Env: 3 | 4 | # Builds a ruby hash compatible string 5 | $hash_string = "{" 6 | 7 | foreach ($item in $env_list) 8 | { 9 | $hash_string += "`"" + $item.Name + "`" => `"" + $item.value.replace('\','\\').replace('"','\"') + "`"," 10 | } 11 | $hash_string += "}" 12 | 13 | $hash_string | out-File -Encoding "UTF8" $p1 14 | -------------------------------------------------------------------------------- /spec/pid_behavior.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | shared_examples_for "a platform that provides the child's pid" do 4 | it "knows the child's pid" do 5 | Tempfile.open("pid-spec") do |file| 6 | process = write_pid(file.path).start 7 | process.wait 8 | 9 | expect(process.pid).to eq rewind_and_read(file).chomp.to_i 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/childprocess/unix/io.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | module Unix 3 | class IO < AbstractIO 4 | private 5 | 6 | def check_type(io) 7 | unless io.respond_to? :to_io 8 | raise ArgumentError, "expected #{io.inspect} to respond to :to_io" 9 | end 10 | 11 | result = io.to_io 12 | unless result && result.kind_of?(::IO) 13 | raise TypeError, "expected IO, got #{result.inspect}:#{result.class}" 14 | end 15 | end 16 | 17 | end # IO 18 | end # Unix 19 | end # ChildProcess 20 | 21 | 22 | -------------------------------------------------------------------------------- /lib/childprocess/windows/io.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | module Windows 3 | class IO < AbstractIO 4 | private 5 | 6 | def check_type(io) 7 | return if has_fileno?(io) 8 | return if has_to_io?(io) 9 | 10 | raise ArgumentError, "#{io.inspect}:#{io.class} must have :fileno or :to_io" 11 | end 12 | 13 | def has_fileno?(io) 14 | io.respond_to?(:fileno) && io.fileno 15 | end 16 | 17 | def has_to_io?(io) 18 | io.respond_to?(:to_io) && io.to_io.kind_of?(::IO) 19 | end 20 | 21 | end # IO 22 | end # Windows 23 | end # ChildProcess 24 | 25 | 26 | -------------------------------------------------------------------------------- /lib/childprocess/abstract_io.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | class AbstractIO 3 | attr_reader :stderr, :stdout, :stdin 4 | 5 | def inherit! 6 | @stdout = STDOUT 7 | @stderr = STDERR 8 | end 9 | 10 | def stderr=(io) 11 | check_type io 12 | @stderr = io 13 | end 14 | 15 | def stdout=(io) 16 | check_type io 17 | @stdout = io 18 | end 19 | 20 | # 21 | # @api private 22 | # 23 | 24 | def _stdin=(io) 25 | check_type io 26 | @stdin = io 27 | end 28 | 29 | private 30 | 31 | def check_type(io) 32 | raise SubclassResponsibility, "check_type" 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/childprocess/windows/process.rb: -------------------------------------------------------------------------------- 1 | require_relative '../process_spawn_process' 2 | 3 | module ChildProcess 4 | module Windows 5 | class Process < ProcessSpawnProcess 6 | def io 7 | @io ||= Windows::IO.new 8 | end 9 | 10 | def stop(timeout = 3) 11 | assert_started 12 | send_kill 13 | 14 | begin 15 | return poll_for_exit(timeout) 16 | rescue TimeoutError 17 | # try next 18 | end 19 | 20 | wait 21 | rescue Errno::ECHILD, Errno::ESRCH 22 | # handle race condition where process dies between timeout 23 | # and send_kill 24 | true 25 | end 26 | end # Process 27 | end # Windows 28 | end # ChildProcess 29 | -------------------------------------------------------------------------------- /lib/childprocess/unix/process.rb: -------------------------------------------------------------------------------- 1 | require_relative '../process_spawn_process' 2 | 3 | module ChildProcess 4 | module Unix 5 | class Process < ProcessSpawnProcess 6 | def io 7 | @io ||= Unix::IO.new 8 | end 9 | 10 | def stop(timeout = 3) 11 | assert_started 12 | send_term 13 | 14 | begin 15 | return poll_for_exit(timeout) 16 | rescue TimeoutError 17 | # try next 18 | end 19 | 20 | send_kill 21 | wait 22 | rescue Errno::ECHILD, Errno::ESRCH 23 | # handle race condition where process dies between timeout 24 | # and send_kill 25 | true 26 | end 27 | end # Process 28 | end # Unix 29 | end # ChildProcess 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ ubuntu, macos, windows ] 9 | ruby: [ '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', jruby, truffleruby ] 10 | exclude: 11 | - { os: windows, ruby: truffleruby } 12 | # fails to load rspec: RuntimeError: CRITICAL: RUBYGEMS_ACTIVATION_MONITOR.owned?: before false -> after true 13 | - { os: windows, ruby: jruby } 14 | runs-on: ${{ matrix.os }}-latest 15 | env: 16 | CHILDPROCESS_UNSET: should-be-unset 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | bundler-cache: true 23 | - run: bundle exec rake spec 24 | -------------------------------------------------------------------------------- /spec/windows_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | require "pid_behavior" 3 | 4 | if ChildProcess.windows? 5 | describe ChildProcess::Windows::Process do 6 | it_behaves_like "a platform that provides the child's pid" 7 | end 8 | 9 | describe ChildProcess::Windows::IO do 10 | let(:io) { ChildProcess::Windows::IO.new } 11 | 12 | it "raises an ArgumentError if given IO does not respond to :fileno" do 13 | expect { io.stdout = nil }.to raise_error(ArgumentError, /must have :fileno or :to_io/) 14 | end 15 | 16 | it "raises an ArgumentError if the #to_io does not return an IO " do 17 | fake_io = Object.new 18 | def fake_io.to_io() StringIO.new end 19 | 20 | expect { io.stdout = fake_io }.to raise_error(ArgumentError, /must have :fileno or :to_io/) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2015 Jari Bakken 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /childprocess.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "childprocess/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "childprocess" 7 | s.version = ChildProcess::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Jari Bakken", "Eric Kessler", "Shane da Silva"] 10 | s.email = ["morrow748@gmail.com", "shane@dasilva.io"] 11 | s.homepage = "https://github.com/enkessler/childprocess" 12 | s.summary = %q{A simple and reliable solution for controlling external programs running in the background on any Ruby / OS combination.} 13 | 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.} 14 | 15 | s.license = 'MIT' 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- spec/*`.split("\n") 19 | s.require_paths = ["lib"] 20 | 21 | s.required_ruby_version = '>= 2.4.0' 22 | 23 | s.add_development_dependency "rspec", "~> 3.0" 24 | s.add_development_dependency "yard", "~> 0.0" 25 | s.add_development_dependency 'coveralls', '< 1.0' 26 | end 27 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'tmpdir' 4 | 5 | require 'bundler' 6 | Bundler::GemHelper.install_tasks 7 | 8 | include Rake::DSL if defined?(::Rake::DSL) 9 | 10 | require 'rspec/core/rake_task' 11 | RSpec::Core::RakeTask.new(:spec) do |spec| 12 | spec.ruby_opts = "-I lib:spec -w" 13 | spec.pattern = 'spec/**/*_spec.rb' 14 | end 15 | 16 | desc 'Run specs for rcov' 17 | RSpec::Core::RakeTask.new(:rcov) do |spec| 18 | spec.ruby_opts = "-I lib:spec" 19 | spec.pattern = 'spec/**/*_spec.rb' 20 | spec.rcov = true 21 | spec.rcov_opts = %w[--exclude spec,ruby-debug,/Library/Ruby,.gem --include lib/childprocess] 22 | end 23 | 24 | task :default => :spec 25 | 26 | begin 27 | require 'yard' 28 | YARD::Rake::YardocTask.new 29 | rescue LoadError 30 | task :yardoc do 31 | abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard" 32 | end 33 | end 34 | 35 | task :clean do 36 | rm_rf "pkg" 37 | rm_rf "childprocess.jar" 38 | end 39 | 40 | desc 'Create jar to bundle in selenium-webdriver' 41 | task :jar => [:clean, :build] do 42 | tmpdir = Dir.mktmpdir("childprocess-jar") 43 | gem_to_package = Dir['pkg/*.gem'].first 44 | gem_name = File.basename(gem_to_package, ".gem") 45 | p :gem_to_package => gem_to_package, :gem_name => gem_name 46 | 47 | sh "gem install -i #{tmpdir} #{gem_to_package} --ignore-dependencies --no-rdoc --no-ri" 48 | sh "jar cf childprocess.jar -C #{tmpdir}/gems/#{gem_name}/lib ." 49 | sh "jar tf childprocess.jar" 50 | end 51 | 52 | task :env do 53 | $:.unshift File.expand_path("../lib", __FILE__) 54 | require 'childprocess' 55 | end 56 | 57 | desc 'Calculate size of posix_spawn structs for the current platform' 58 | task :generate => :env do 59 | require 'childprocess/tools/generator' 60 | ChildProcess::Tools::Generator.generate 61 | end 62 | -------------------------------------------------------------------------------- /spec/unix_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | require "pid_behavior" 3 | 4 | if ChildProcess.unix? 5 | 6 | describe ChildProcess::Unix::Process do 7 | it_behaves_like "a platform that provides the child's pid" 8 | 9 | it "handles ECHILD race condition where process dies between timeout and KILL" do 10 | process = sleeping_ruby 11 | 12 | allow(Process).to receive(:spawn).and_return('fakepid') 13 | allow(process).to receive(:send_term) 14 | allow(process).to receive(:poll_for_exit).and_raise(ChildProcess::TimeoutError) 15 | allow(process).to receive(:send_kill).and_raise(Errno::ECHILD.new) 16 | 17 | process.start 18 | expect { process.stop }.not_to raise_error 19 | 20 | allow(process).to receive(:alive?).and_return(false) 21 | end 22 | 23 | it "handles ESRCH race condition where process dies between timeout and KILL" do 24 | process = sleeping_ruby 25 | 26 | allow(Process).to receive(:spawn).and_return('fakepid') 27 | allow(process).to receive(:send_term) 28 | allow(process).to receive(:poll_for_exit).and_raise(ChildProcess::TimeoutError) 29 | allow(process).to receive(:send_kill).and_raise(Errno::ESRCH.new) 30 | 31 | process.start 32 | expect { process.stop }.not_to raise_error 33 | 34 | allow(process).to receive(:alive?).and_return(false) 35 | end 36 | end 37 | 38 | describe ChildProcess::Unix::IO do 39 | let(:io) { ChildProcess::Unix::IO.new } 40 | 41 | it "raises an ArgumentError if given IO does not respond to :to_io" do 42 | expect { io.stdout = nil }.to raise_error(ArgumentError, /to respond to :to_io/) 43 | end 44 | 45 | it "raises a TypeError if #to_io does not return an IO" do 46 | fake_io = Object.new 47 | def fake_io.to_io() StringIO.new end 48 | 49 | expect { io.stdout = fake_io }.to raise_error(TypeError, /expected IO, got/) 50 | end 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /spec/platform_detection_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | # Q: Should platform detection concern be extracted from ChildProcess? 4 | describe ChildProcess do 5 | 6 | describe ".arch" do 7 | subject { described_class.arch } 8 | 9 | before(:each) { described_class.instance_variable_set(:@arch, nil) } 10 | 11 | after(:each) { described_class.instance_variable_set(:@arch, nil) } 12 | 13 | shared_examples 'expected_arch_for_host_cpu' do |host_cpu, expected_arch| 14 | context "when host_cpu is '#{host_cpu}'" do 15 | before :each do 16 | allow(RbConfig::CONFIG). 17 | to receive(:[]). 18 | with('host_cpu'). 19 | and_return(expected_arch) 20 | end 21 | 22 | it { is_expected.to eq expected_arch } 23 | end 24 | end 25 | 26 | # Normal cases: not macosx - depends only on host_cpu 27 | context "when os is *not* 'macosx'" do 28 | before :each do 29 | allow(described_class).to receive(:os).and_return(:not_macosx) 30 | end 31 | 32 | [ 33 | { host_cpu: 'i386', expected_arch: 'i386' }, 34 | { host_cpu: 'i486', expected_arch: 'i386' }, 35 | { host_cpu: 'i586', expected_arch: 'i386' }, 36 | { host_cpu: 'i686', expected_arch: 'i386' }, 37 | { host_cpu: 'amd64', expected_arch: 'x86_64' }, 38 | { host_cpu: 'x86_64', expected_arch: 'x86_64' }, 39 | { host_cpu: 'ppc', expected_arch: 'powerpc' }, 40 | { host_cpu: 'powerpc', expected_arch: 'powerpc' }, 41 | { host_cpu: 'unknown', expected_arch: 'unknown' }, 42 | ].each do |args| 43 | include_context 'expected_arch_for_host_cpu', args.values 44 | end 45 | end 46 | 47 | # Special cases: macosx - when host_cpu is i686, have to re-check 48 | context "when os is 'macosx'" do 49 | before :each do 50 | allow(described_class).to receive(:os).and_return(:macosx) 51 | end 52 | 53 | context "when host_cpu is 'i686' " do 54 | shared_examples 'expected_arch_on_macosx_i686' do |is_64, expected_arch| 55 | context "when Ruby is #{is_64 ? 64 : 32}-bit" do 56 | before :each do 57 | allow(described_class). 58 | to receive(:is_64_bit?). 59 | and_return(is_64) 60 | end 61 | 62 | include_context 'expected_arch_for_host_cpu', 'i686', expected_arch 63 | end 64 | end 65 | 66 | [ 67 | { is_64: true, expected_arch: 'x86_64' }, 68 | { is_64: false, expected_arch: 'i386' } 69 | ].each do |args| 70 | include_context 'expected_arch_on_macosx_i686', args.values 71 | end 72 | end 73 | 74 | [ 75 | { host_cpu: 'amd64', expected_arch: 'x86_64' }, 76 | { host_cpu: 'x86_64', expected_arch: 'x86_64' }, 77 | { host_cpu: 'ppc', expected_arch: 'powerpc' }, 78 | { host_cpu: 'powerpc', expected_arch: 'powerpc' }, 79 | { host_cpu: 'unknown', expected_arch: 'unknown' }, 80 | ].each do |args| 81 | include_context 'expected_arch_for_host_cpu', args.values 82 | end 83 | end 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /lib/childprocess/process_spawn_process.rb: -------------------------------------------------------------------------------- 1 | require_relative 'abstract_process' 2 | 3 | module ChildProcess 4 | class ProcessSpawnProcess < AbstractProcess 5 | attr_reader :pid 6 | 7 | def exited? 8 | return true if @exit_code 9 | 10 | assert_started 11 | pid, status = ::Process.waitpid2(@pid, ::Process::WNOHANG | ::Process::WUNTRACED) 12 | pid = nil if pid == 0 # may happen on jruby 13 | 14 | log(:pid => pid, :status => status) 15 | 16 | if pid 17 | set_exit_code(status) 18 | end 19 | 20 | !!pid 21 | rescue Errno::ECHILD 22 | # may be thrown for detached processes 23 | true 24 | end 25 | 26 | def wait 27 | assert_started 28 | 29 | if exited? 30 | exit_code 31 | else 32 | _, status = ::Process.waitpid2(@pid) 33 | 34 | set_exit_code(status) 35 | end 36 | end 37 | 38 | private 39 | 40 | def launch_process 41 | environment = {} 42 | @environment.each_pair do |key, value| 43 | key = key.to_s 44 | value = value.nil? ? nil : value.to_s 45 | 46 | if key.include?("\0") || key.include?("=") || value.to_s.include?("\0") 47 | raise InvalidEnvironmentVariable, "#{key.inspect} => #{value.to_s.inspect}" 48 | end 49 | environment[key] = value 50 | end 51 | 52 | options = {} 53 | 54 | options[:out] = io.stdout ? io.stdout.fileno : File::NULL 55 | options[:err] = io.stderr ? io.stderr.fileno : File::NULL 56 | 57 | if duplex? 58 | reader, writer = ::IO.pipe 59 | options[:in] = reader.fileno 60 | unless ChildProcess.windows? 61 | options[writer.fileno] = :close 62 | end 63 | end 64 | 65 | if leader? 66 | if ChildProcess.windows? 67 | options[:new_pgroup] = true 68 | else 69 | options[:pgroup] = true 70 | end 71 | end 72 | 73 | options[:chdir] = @cwd if @cwd 74 | 75 | if @args.size == 1 76 | # When given a single String, Process.spawn would think it should use the shell 77 | # if there is any special character in it. However, ChildProcess should never 78 | # use the shell. So we use the [cmdname, argv0] form to force no shell. 79 | arg = @args[0] 80 | args = [[arg, arg]] 81 | else 82 | args = @args 83 | end 84 | 85 | begin 86 | @pid = ::Process.spawn(environment, *args, options) 87 | rescue SystemCallError => e 88 | raise LaunchError, e.message 89 | end 90 | 91 | if duplex? 92 | io._stdin = writer 93 | reader.close 94 | end 95 | 96 | ::Process.detach(@pid) if detach? 97 | end 98 | 99 | def set_exit_code(status) 100 | @exit_code = status.exitstatus || status.termsig 101 | end 102 | 103 | def send_term 104 | send_signal 'TERM' 105 | end 106 | 107 | def send_kill 108 | send_signal 'KILL' 109 | end 110 | 111 | def send_signal(sig) 112 | assert_started 113 | 114 | log "sending #{sig}" 115 | if leader? 116 | if ChildProcess.unix? 117 | ::Process.kill sig, -@pid # negative pid == process group 118 | else 119 | output = `taskkill /F /T /PID #{@pid}` 120 | log output 121 | end 122 | else 123 | ::Process.kill sig, @pid 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Version 5.0.0 / 2024-01-06 2 | 3 | * [#175](https://github.com/enkessler/childprocess/pull/175): Replace all backends by `Process.spawn` for portability, reliability and simplicity. 4 | * [#185](https://github.com/enkessler/childprocess/pull/185): Add support for Ruby 3.x 5 | 6 | ### Version 4.1.0 / 2021-06-08 7 | 8 | * [#170](https://github.com/enkessler/childprocess/pull/170): Update gem homepage to use `https://` 9 | * [#177](https://github.com/enkessler/childprocess/pull/177): Add ARM64-macos support 10 | 11 | ### Version 4.0.0 / 2020-06-18 12 | 13 | * [#167](https://github.com/enkessler/childprocess/pull/167): Fix detach behavior on Windows 14 | * [#168](https://github.com/enkessler/childprocess/pull/168): Drop support for Ruby 2.3 15 | 16 | ### Version 3.0.0 / 2019-09-20 17 | 18 | * [#156](https://github.com/enkessler/childprocess/pull/156): Remove unused `rubyforge_project` from gemspec 19 | * [#160](https://github.com/enkessler/childprocess/pull/160): Remove extension to conditionally install `ffi` gem on Windows platforms 20 | * [#160](https://github.com/enkessler/childprocess/pull/160): Remove runtime dependency on `rake` gem 21 | 22 | ### Version 2.0.0 / 2019-07-11 23 | 24 | * [#148](https://github.com/enkessler/childprocess/pull/148): Drop support for Ruby 2.0, 2.1, and 2.2 25 | * [#149](https://github.com/enkessler/childprocess/pull/149): Fix Unix fork reopen to be compatible with Ruby 2.6 26 | * [#152](https://github.com/enkessler/childprocess/pull/152)/[#154](https://github.com/enkessler/childprocess/pull/154): Fix hangs and permission errors introduced in Ruby 2.6 for leader processes of process groups 27 | 28 | ### Version 1.0.1 / 2019-02-03 29 | 30 | * [#143](https://github.com/enkessler/childprocess/pull/144): Fix installs by adding `rake` gem as runtime dependency 31 | * [#147](https://github.com/enkessler/childprocess/pull/147): Relax `rake` gem constraint from `< 12` to `< 13` 32 | 33 | ### Version 1.0.0 / 2019-01-28 34 | 35 | * [#134](https://github.com/enkessler/childprocess/pull/134): Add support for non-ASCII characters on Windows 36 | * [#132](https://github.com/enkessler/childprocess/pull/132): Install `ffi` gem requirement on Windows only 37 | * [#128](https://github.com/enkessler/childprocess/issues/128): Convert environment variable values to strings when `posix_spawn` enabled 38 | * [#141](https://github.com/enkessler/childprocess/pull/141): Support JRuby on Java >= 9 39 | 40 | ### Version 0.9.0 / 2018-03-10 41 | 42 | * Added support for DragonFly BSD. 43 | 44 | 45 | ### Version 0.8.0 / 2017-09-23 46 | 47 | * Added a method for determining whether or not a process had been started. 48 | 49 | 50 | ### Version 0.7.1 / 2017-06-26 51 | 52 | * Fixed a noisy uninitialized variable warning 53 | 54 | 55 | ### Version 0.7.0 / 2017-05-07 56 | 57 | * Debugging information now uses a Logger, which can be configured. 58 | 59 | 60 | ### Version 0.6.3 / 2017-03-24 61 | 62 | See beta release notes. 63 | 64 | 65 | ### Version 0.6.3.beta.1 / 2017-03-10 66 | 67 | * Bug fix: Fixed child process creation problems on Windows 7 when a child was declared as a leader. 68 | 69 | 70 | ### Version 0.6.2 / 2017-02-25 71 | 72 | * Bug fix: Fixed a potentially broken edge case that could occur on older 32-bit OSX systems. 73 | 74 | 75 | ### Version 0.6.1 / 2017-01-22 76 | 77 | * Bug fix: Fixed a dependency that was accidentally declared as a runtime 78 | dependency instead of a development dependency. 79 | 80 | 81 | ### Version 0.6.0 / 2017-01-22 82 | 83 | * Support for Ruby 2.4 added 84 | 85 | 86 | ### Version 0.5.9 / 2016-01-06 87 | 88 | * The Great Before Times... 89 | -------------------------------------------------------------------------------- /lib/childprocess.rb: -------------------------------------------------------------------------------- 1 | require 'childprocess/version' 2 | require 'childprocess/errors' 3 | require 'childprocess/abstract_process' 4 | require 'childprocess/abstract_io' 5 | require 'childprocess/process_spawn_process' 6 | require "fcntl" 7 | require 'logger' 8 | 9 | module ChildProcess 10 | 11 | @posix_spawn = false 12 | 13 | class << self 14 | attr_writer :logger 15 | 16 | def new(*args) 17 | case os 18 | when :macosx, :linux, :solaris, :bsd, :cygwin, :aix 19 | Unix::Process.new(*args) 20 | when :windows 21 | Windows::Process.new(*args) 22 | else 23 | raise Error, "unsupported platform #{platform_name.inspect}" 24 | end 25 | end 26 | alias_method :build, :new 27 | 28 | def logger 29 | return @logger if defined?(@logger) and @logger 30 | 31 | @logger = Logger.new($stderr) 32 | @logger.level = $DEBUG ? Logger::DEBUG : Logger::INFO 33 | 34 | @logger 35 | end 36 | 37 | def platform 38 | os 39 | end 40 | 41 | def platform_name 42 | @platform_name ||= "#{arch}-#{os}" 43 | end 44 | 45 | def unix? 46 | !windows? 47 | end 48 | 49 | def linux? 50 | os == :linux 51 | end 52 | 53 | def jruby? 54 | RUBY_ENGINE == 'jruby' 55 | end 56 | 57 | def windows? 58 | os == :windows 59 | end 60 | 61 | def posix_spawn_chosen_explicitly? 62 | @posix_spawn || %w[1 true].include?(ENV['CHILDPROCESS_POSIX_SPAWN']) 63 | end 64 | 65 | def posix_spawn? 66 | false 67 | end 68 | 69 | # 70 | # Set this to true to enable experimental use of posix_spawn. 71 | # 72 | 73 | def posix_spawn=(bool) 74 | @posix_spawn = bool 75 | end 76 | 77 | def os 78 | return :windows if ENV['FAKE_WINDOWS'] == 'true' 79 | 80 | @os ||= ( 81 | require "rbconfig" 82 | host_os = RbConfig::CONFIG['host_os'].downcase 83 | 84 | case host_os 85 | when /linux/ 86 | :linux 87 | when /darwin|mac os/ 88 | :macosx 89 | when /mswin|msys|mingw32/ 90 | :windows 91 | when /cygwin/ 92 | :cygwin 93 | when /solaris|sunos/ 94 | :solaris 95 | when /bsd|dragonfly/ 96 | :bsd 97 | when /aix/ 98 | :aix 99 | else 100 | raise Error, "unknown os: #{host_os.inspect}" 101 | end 102 | ) 103 | end 104 | 105 | def arch 106 | @arch ||= ( 107 | host_cpu = RbConfig::CONFIG['host_cpu'].downcase 108 | case host_cpu 109 | when /i[3456]86/ 110 | if workaround_older_macosx_misreported_cpu? 111 | # Workaround case: older 64-bit Darwin Rubies misreported as i686 112 | "x86_64" 113 | else 114 | "i386" 115 | end 116 | when /amd64|x86_64/ 117 | "x86_64" 118 | when /ppc|powerpc/ 119 | "powerpc" 120 | else 121 | host_cpu 122 | end 123 | ) 124 | end 125 | 126 | # 127 | # By default, a child process will inherit open file descriptors from the 128 | # parent process. This helper provides a cross-platform way of making sure 129 | # that doesn't happen for the given file/io. 130 | # 131 | 132 | def close_on_exec(file) 133 | if file.respond_to?(:close_on_exec=) 134 | file.close_on_exec = true 135 | else 136 | raise Error, "not sure how to set close-on-exec for #{file.inspect} on #{platform_name.inspect}" 137 | end 138 | end 139 | 140 | private 141 | 142 | def warn_once(msg) 143 | @warnings ||= {} 144 | 145 | unless @warnings[msg] 146 | @warnings[msg] = true 147 | logger.warn msg 148 | end 149 | end 150 | 151 | # Workaround: detect the situation that an older Darwin Ruby is actually 152 | # 64-bit, but is misreporting cpu as i686, which would imply 32-bit. 153 | # 154 | # @return [Boolean] `true` if: 155 | # (a) on Mac OS X 156 | # (b) actually running in 64-bit mode 157 | def workaround_older_macosx_misreported_cpu? 158 | os == :macosx && is_64_bit? 159 | end 160 | 161 | # @return [Boolean] `true` if this Ruby represents `1` in 64 bits (8 bytes). 162 | def is_64_bit? 163 | 1.size == 8 164 | end 165 | 166 | end # class << self 167 | end # ChildProcess 168 | 169 | if ChildProcess.windows? 170 | require 'childprocess/windows' 171 | else 172 | require 'childprocess/unix' 173 | end 174 | -------------------------------------------------------------------------------- /lib/childprocess/abstract_process.rb: -------------------------------------------------------------------------------- 1 | module ChildProcess 2 | class AbstractProcess 3 | POLL_INTERVAL = 0.1 4 | 5 | attr_reader :exit_code 6 | 7 | # 8 | # Set this to true if you do not care about when or if the process quits. 9 | # 10 | attr_accessor :detach 11 | 12 | # 13 | # Set this to true if you want to write to the process' stdin (process.io.stdin) 14 | # 15 | attr_accessor :duplex 16 | 17 | # 18 | # Modify the child's environment variables 19 | # 20 | attr_reader :environment 21 | 22 | # 23 | # Set the child's current working directory. 24 | # 25 | attr_accessor :cwd 26 | 27 | # 28 | # Set this to true to make the child process the leader of a new process group 29 | # 30 | # This can be used to make sure that all grandchildren are killed 31 | # when the child process dies. 32 | # 33 | attr_accessor :leader 34 | 35 | # 36 | # Create a new process with the given args. 37 | # 38 | # @api private 39 | # @see ChildProcess.build 40 | # 41 | 42 | def initialize(*args) 43 | unless args.all? { |e| e.kind_of?(String) } 44 | raise ArgumentError, "all arguments must be String: #{args.inspect}" 45 | end 46 | 47 | @args = args 48 | @started = false 49 | @exit_code = nil 50 | @io = nil 51 | @cwd = nil 52 | @detach = false 53 | @duplex = false 54 | @leader = false 55 | @environment = {} 56 | end 57 | 58 | # 59 | # Returns a ChildProcess::AbstractIO subclass to configure the child's IO streams. 60 | # 61 | 62 | def io 63 | raise SubclassResponsibility, "io" 64 | end 65 | 66 | # 67 | # @return [Integer] the pid of the process after it has started 68 | # 69 | 70 | def pid 71 | raise SubclassResponsibility, "pid" 72 | end 73 | 74 | # 75 | # Launch the child process 76 | # 77 | # @return [AbstractProcess] self 78 | # 79 | 80 | def start 81 | launch_process 82 | @started = true 83 | 84 | self 85 | end 86 | 87 | # 88 | # Forcibly terminate the process, using increasingly harsher methods if possible. 89 | # 90 | # @param [Integer] timeout (3) Seconds to wait before trying the next method. 91 | # 92 | 93 | def stop(timeout = 3) 94 | raise SubclassResponsibility, "stop" 95 | end 96 | 97 | # 98 | # Block until the process has been terminated. 99 | # 100 | # @return [Integer] The exit status of the process 101 | # 102 | 103 | def wait 104 | raise SubclassResponsibility, "wait" 105 | end 106 | 107 | # 108 | # Did the process exit? 109 | # 110 | # @return [Boolean] 111 | # 112 | 113 | def exited? 114 | raise SubclassResponsibility, "exited?" 115 | end 116 | 117 | # 118 | # Has the process started? 119 | # 120 | # @return [Boolean] 121 | # 122 | 123 | def started? 124 | @started 125 | end 126 | 127 | # 128 | # Is this process running? 129 | # 130 | # @return [Boolean] 131 | # 132 | 133 | def alive? 134 | started? && !exited? 135 | end 136 | 137 | # 138 | # Returns true if the process has exited and the exit code was not 0. 139 | # 140 | # @return [Boolean] 141 | # 142 | 143 | def crashed? 144 | exited? && @exit_code != 0 145 | end 146 | 147 | # 148 | # Wait for the process to exit, raising a ChildProcess::TimeoutError if 149 | # the timeout expires. 150 | # 151 | 152 | def poll_for_exit(timeout) 153 | log "polling #{timeout} seconds for exit" 154 | 155 | end_time = Time.now + timeout 156 | until (ok = exited?) || Time.now > end_time 157 | sleep POLL_INTERVAL 158 | end 159 | 160 | unless ok 161 | raise TimeoutError, "process still alive after #{timeout} seconds" 162 | end 163 | end 164 | 165 | private 166 | 167 | def launch_process 168 | raise SubclassResponsibility, "launch_process" 169 | end 170 | 171 | def detach? 172 | @detach 173 | end 174 | 175 | def duplex? 176 | @duplex 177 | end 178 | 179 | def leader? 180 | @leader 181 | end 182 | 183 | def log(*args) 184 | ChildProcess.logger.debug "#{self.inspect} : #{args.inspect}" 185 | end 186 | 187 | def assert_started 188 | raise Error, "process not started" unless started? 189 | end 190 | 191 | end # AbstractProcess 192 | end # ChildProcess 193 | -------------------------------------------------------------------------------- /spec/io_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | describe ChildProcess do 4 | it "can run even when $stdout is a StringIO" do 5 | begin 6 | stdout = $stdout 7 | $stdout = StringIO.new 8 | expect { sleeping_ruby.start }.to_not raise_error 9 | ensure 10 | $stdout = stdout 11 | end 12 | end 13 | 14 | it "can redirect stdout, stderr" do 15 | process = ruby(<<-CODE) 16 | [STDOUT, STDERR].each_with_index do |io, idx| 17 | io.sync = true 18 | io.puts idx 19 | end 20 | CODE 21 | 22 | out = Tempfile.new("stdout-spec") 23 | err = Tempfile.new("stderr-spec") 24 | 25 | begin 26 | process.io.stdout = out 27 | process.io.stderr = err 28 | 29 | process.start 30 | expect(process.io.stdin).to be_nil 31 | process.wait 32 | 33 | expect(rewind_and_read(out)).to eq "0\n" 34 | expect(rewind_and_read(err)).to eq "1\n" 35 | ensure 36 | out.close 37 | err.close 38 | end 39 | end 40 | 41 | it "can redirect stdout only" do 42 | process = ruby(<<-CODE) 43 | [STDOUT, STDERR].each_with_index do |io, idx| 44 | io.sync = true 45 | io.puts idx 46 | end 47 | CODE 48 | 49 | out = Tempfile.new("stdout-spec") 50 | 51 | begin 52 | process.io.stdout = out 53 | 54 | process.start 55 | process.wait 56 | 57 | expect(rewind_and_read(out)).to eq "0\n" 58 | ensure 59 | out.close 60 | end 61 | end 62 | 63 | it "pumps all output" do 64 | process = echo 65 | 66 | out = Tempfile.new("pump") 67 | 68 | begin 69 | process.io.stdout = out 70 | 71 | process.start 72 | process.wait 73 | 74 | expect(rewind_and_read(out)).to eq "hello\n" 75 | ensure 76 | out.close 77 | end 78 | end 79 | 80 | it "can write to stdin if duplex = true" do 81 | process = cat 82 | 83 | out = Tempfile.new("duplex") 84 | out.sync = true 85 | 86 | begin 87 | process.io.stdout = out 88 | process.io.stderr = out 89 | process.duplex = true 90 | 91 | process.start 92 | process.io.stdin.puts "hello world" 93 | process.io.stdin.close 94 | 95 | process.poll_for_exit(exit_timeout) 96 | 97 | expect(rewind_and_read(out)).to eq "hello world\n" 98 | ensure 99 | out.close 100 | end 101 | end 102 | 103 | it "can write to stdin interactively if duplex = true" do 104 | process = cat 105 | 106 | out = Tempfile.new("duplex") 107 | out.sync = true 108 | 109 | out_receiver = File.open(out.path, "rb") 110 | begin 111 | process.io.stdout = out 112 | process.io.stderr = out 113 | process.duplex = true 114 | 115 | process.start 116 | 117 | stdin = process.io.stdin 118 | 119 | stdin.puts "hello" 120 | stdin.flush 121 | wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\n\z/m) } 122 | 123 | stdin.putc "n" 124 | stdin.flush 125 | wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nn\z/m) } 126 | 127 | stdin.print "e" 128 | stdin.flush 129 | wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nne\z/m) } 130 | 131 | stdin.printf "w" 132 | stdin.flush 133 | wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nnew\z/m) } 134 | 135 | stdin.write "\nworld\n" 136 | stdin.flush 137 | wait_until { expect(rewind_and_read(out_receiver)).to match(/\Ahello\r?\nnew\r?\nworld\r?\n\z/m) } 138 | 139 | stdin.close 140 | process.poll_for_exit(exit_timeout) 141 | ensure 142 | out_receiver.close 143 | out.close 144 | end 145 | end 146 | 147 | # 148 | # this works on JRuby 1.6.5 on my Mac, but for some reason 149 | # hangs on Travis (running 1.6.5.1 + OpenJDK). 150 | # 151 | # http://travis-ci.org/#!/enkessler/childprocess/jobs/487331 152 | # 153 | 154 | it "works with pipes" do 155 | process = ruby(<<-CODE) 156 | STDOUT.print "stdout" 157 | STDERR.print "stderr" 158 | CODE 159 | 160 | stdout, stdout_w = IO.pipe 161 | stderr, stderr_w = IO.pipe 162 | 163 | process.io.stdout = stdout_w 164 | process.io.stderr = stderr_w 165 | 166 | process.duplex = true 167 | 168 | process.start 169 | process.wait 170 | 171 | # write streams are closed *after* the process 172 | # has exited - otherwise it won't work on JRuby 173 | # with the current Process implementation 174 | 175 | stdout_w.close 176 | stderr_w.close 177 | 178 | out = stdout.read 179 | err = stderr.read 180 | 181 | expect([out, err]).to eq %w[stdout stderr] 182 | end 183 | 184 | it "can set close-on-exec when IO is inherited" do 185 | port = random_free_port 186 | server = TCPServer.new("127.0.0.1", port) 187 | ChildProcess.close_on_exec server 188 | 189 | process = sleeping_ruby 190 | process.io.inherit! 191 | 192 | process.start 193 | server.close 194 | 195 | wait_until { can_bind? "127.0.0.1", port } 196 | end 197 | 198 | it "handles long output" do 199 | process = ruby <<-CODE 200 | print 'a'*3000 201 | CODE 202 | 203 | out = Tempfile.new("long-output") 204 | out.sync = true 205 | 206 | begin 207 | process.io.stdout = out 208 | 209 | process.start 210 | process.wait 211 | 212 | expect(rewind_and_read(out).size).to eq 3000 213 | ensure 214 | out.close 215 | end 216 | end 217 | 218 | it 'should not inherit stdout and stderr by default' do 219 | cap = capture_std do 220 | process = echo 221 | process.start 222 | process.wait 223 | end 224 | 225 | expect(cap.stdout).to eq '' 226 | expect(cap.stderr).to eq '' 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 3 | 4 | unless defined?(JRUBY_VERSION) 5 | require 'coveralls' 6 | Coveralls.wear! 7 | end 8 | 9 | require 'childprocess' 10 | require 'rspec' 11 | require 'tempfile' 12 | require 'socket' 13 | require 'stringio' 14 | require 'ostruct' 15 | 16 | module ChildProcessSpecHelper 17 | RUBY = defined?(Gem) ? Gem.ruby : 'ruby' 18 | 19 | def ruby_process(*args) 20 | @process = ChildProcess.build(RUBY , *args) 21 | end 22 | 23 | def windows_process(*args) 24 | @process = ChildProcess.build("powershell", *args) 25 | end 26 | 27 | def sleeping_ruby(seconds = nil) 28 | if seconds 29 | ruby_process("-e", "sleep #{seconds}") 30 | else 31 | ruby_process("-e", "sleep") 32 | end 33 | end 34 | 35 | def invalid_process 36 | @process = ChildProcess.build("unlikelytoexist") 37 | end 38 | 39 | def ignored(signal) 40 | code = <<-RUBY 41 | trap(#{signal.inspect}, "IGNORE") 42 | sleep 43 | RUBY 44 | 45 | ruby_process tmp_script(code) 46 | end 47 | 48 | def write_env(path) 49 | if ChildProcess.os == :windows 50 | ps_env_file_path = File.expand_path(File.dirname(__FILE__)) 51 | args = ['-File', "#{ps_env_file_path}/get_env.ps1", path] 52 | windows_process(*args) 53 | else 54 | code = <<-RUBY 55 | File.open(#{path.inspect}, "w") { |f| f << ENV.inspect } 56 | RUBY 57 | ruby_process tmp_script(code) 58 | end 59 | end 60 | 61 | def write_argv(path, *args) 62 | code = <<-RUBY 63 | File.open(#{path.inspect}, "w") { |f| f << ARGV.inspect } 64 | RUBY 65 | 66 | ruby_process(tmp_script(code), *args) 67 | end 68 | 69 | def write_pid(path) 70 | code = <<-RUBY 71 | File.open(#{path.inspect}, "w") { |f| f << Process.pid } 72 | RUBY 73 | 74 | ruby_process tmp_script(code) 75 | end 76 | 77 | def write_pid_in_sleepy_grand_child(path) 78 | code = <<-RUBY 79 | system "ruby", "-e", 'File.open(#{path.inspect}, "w") { |f| f << Process.pid; f.flush }; sleep' 80 | RUBY 81 | 82 | ruby_process tmp_script(code) 83 | end 84 | 85 | def exit_with(exit_code) 86 | ruby_process(tmp_script("exit(#{exit_code})")) 87 | end 88 | 89 | def with_env(hash) 90 | hash.each { |k,v| ENV[k] = v } 91 | begin 92 | yield 93 | ensure 94 | hash.each_key { |k| ENV[k] = nil } 95 | end 96 | end 97 | 98 | def tmp_script(code) 99 | # use an ivar to avoid GC 100 | @tf = Tempfile.new("childprocess-temp") 101 | @tf << code 102 | @tf.close 103 | 104 | puts code if $DEBUG 105 | 106 | @tf.path 107 | end 108 | 109 | def cat 110 | if ChildProcess.os == :windows 111 | ruby(<<-CODE) 112 | STDIN.sync = STDOUT.sync = true 113 | IO.copy_stream(STDIN, STDOUT) 114 | CODE 115 | else 116 | ChildProcess.build("cat") 117 | end 118 | end 119 | 120 | def echo 121 | if ChildProcess.os == :windows 122 | ruby(<<-CODE) 123 | STDIN.sync = true 124 | STDOUT.sync = true 125 | 126 | puts "hello" 127 | CODE 128 | else 129 | ChildProcess.build("echo", "hello") 130 | end 131 | end 132 | 133 | def ruby(code) 134 | ruby_process(tmp_script(code)) 135 | end 136 | 137 | def with_executable_at(path, &blk) 138 | if ChildProcess.os == :windows 139 | path << ".cmd" 140 | content = "#{RUBY} -e 'sleep 10' \n @echo foo" 141 | else 142 | content = "#!/bin/sh\nsleep 10\necho foo" 143 | end 144 | 145 | File.open(path, 'w', 0744) { |io| io << content } 146 | proc = ChildProcess.build(path) 147 | 148 | begin 149 | yield proc 150 | ensure 151 | proc.stop if proc.alive? 152 | File.delete path 153 | end 154 | end 155 | 156 | def exit_timeout 157 | 10 158 | end 159 | 160 | def random_free_port 161 | server = TCPServer.new('127.0.0.1', 0) 162 | port = server.addr[1] 163 | server.close 164 | 165 | port 166 | end 167 | 168 | def with_tmpdir(&blk) 169 | name = "#{Time.now.strftime("%Y%m%d")}-#{$$}-#{rand(0x100000000).to_s(36)}" 170 | FileUtils.mkdir_p(name) 171 | 172 | begin 173 | yield File.expand_path(name) 174 | ensure 175 | FileUtils.rm_rf name 176 | end 177 | end 178 | 179 | def wait_until(timeout = 10, &blk) 180 | end_time = Time.now + timeout 181 | last_exception = nil 182 | 183 | until Time.now >= end_time 184 | begin 185 | result = yield 186 | return result if result 187 | rescue RSpec::Expectations::ExpectationNotMetError => ex 188 | last_exception = ex 189 | end 190 | 191 | sleep 0.01 192 | end 193 | 194 | msg = "timed out after #{timeout} seconds" 195 | msg << ":\n#{last_exception.message}" if last_exception 196 | 197 | raise msg 198 | end 199 | 200 | def can_bind?(host, port) 201 | TCPServer.new(host, port).close 202 | true 203 | rescue 204 | false 205 | end 206 | 207 | def rewind_and_read(io) 208 | io.rewind 209 | io.read 210 | end 211 | 212 | def alive?(pid) 213 | !!Process.kill(0, pid) 214 | rescue Errno::ESRCH 215 | false 216 | end 217 | 218 | def capture_std 219 | orig_out = STDOUT.clone 220 | orig_err = STDERR.clone 221 | 222 | out = Tempfile.new 'captured-stdout' 223 | err = Tempfile.new 'captured-stderr' 224 | out.sync = true 225 | err.sync = true 226 | 227 | STDOUT.reopen out 228 | STDERR.reopen err 229 | 230 | yield 231 | 232 | OpenStruct.new stdout: rewind_and_read(out), stderr: rewind_and_read(err) 233 | ensure 234 | STDOUT.reopen orig_out 235 | STDERR.reopen orig_err 236 | end 237 | 238 | def generate_log_messages 239 | ChildProcess.logger.level = Logger::DEBUG 240 | 241 | process = exit_with(0).start 242 | process.wait 243 | process.poll_for_exit(0.1) 244 | end 245 | 246 | end # ChildProcessSpecHelper 247 | 248 | Thread.abort_on_exception = true 249 | 250 | RSpec.configure do |c| 251 | c.include(ChildProcessSpecHelper) 252 | c.after(:each) { 253 | defined?(@process) && @process.alive? && @process.stop 254 | } 255 | end 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # childprocess 2 | 3 | This gem aims at being a simple and reliable solution for controlling 4 | external programs running in the background on any Ruby / OS combination. 5 | 6 | The code originated in the [selenium-webdriver](https://rubygems.org/gems/selenium-webdriver) gem, but should prove useful as 7 | a standalone library. 8 | 9 | [![CI](https://github.com/enkessler/childprocess/actions/workflows/ci.yml/badge.svg)](https://github.com/enkessler/childprocess/actions/workflows/ci.yml) 10 | ![Gem Version](https://img.shields.io/gem/v/childprocess) 11 | [![Code Climate](https://codeclimate.com/github/enkessler/childprocess.svg)](https://codeclimate.com/github/enkessler/childprocess) 12 | [![Coverage Status](https://coveralls.io/repos/enkessler/childprocess/badge.svg?branch=master)](https://coveralls.io/r/enkessler/childprocess?branch=master) 13 | 14 | # Requirements 15 | 16 | * Ruby 2.4+, JRuby 9+ 17 | 18 | # Usage 19 | 20 | The object returned from `ChildProcess.build` will implement `ChildProcess::AbstractProcess`. 21 | 22 | ### Basic examples 23 | 24 | ```ruby 25 | process = ChildProcess.build("ruby", "-e", "sleep") 26 | 27 | # inherit stdout/stderr from parent... 28 | process.io.inherit! 29 | 30 | # ...or pass an IO 31 | process.io.stdout = Tempfile.new("child-output") 32 | 33 | # modify the environment for the child 34 | process.environment["a"] = "b" 35 | process.environment["c"] = nil 36 | 37 | # set the child's working directory 38 | process.cwd = '/some/path' 39 | 40 | # start the process 41 | process.start 42 | 43 | # check process status 44 | process.alive? #=> true 45 | process.exited? #=> false 46 | 47 | # wait indefinitely for process to exit... 48 | process.wait 49 | process.exited? #=> true 50 | 51 | # get the exit code 52 | process.exit_code #=> 0 53 | 54 | # ...or poll for exit + force quit 55 | begin 56 | process.poll_for_exit(10) 57 | rescue ChildProcess::TimeoutError 58 | process.stop # tries increasingly harsher methods to kill the process. 59 | end 60 | ``` 61 | 62 | ### Advanced examples 63 | 64 | #### Output to pipe 65 | 66 | ```ruby 67 | r, w = IO.pipe 68 | 69 | begin 70 | process = ChildProcess.build("sh" , "-c", 71 | "for i in {1..3}; do echo $i; sleep 1; done") 72 | process.io.stdout = w 73 | process.start # This results in a subprocess inheriting the write end of the pipe. 74 | 75 | # Close parent's copy of the write end of the pipe so when the child 76 | # process closes its write end of the pipe the parent receives EOF when 77 | # attempting to read from it. If the parent leaves its write end open, it 78 | # will not detect EOF. 79 | w.close 80 | 81 | thread = Thread.new do 82 | begin 83 | loop do 84 | print r.readpartial(16384) 85 | end 86 | rescue EOFError 87 | # Child has closed the write end of the pipe 88 | end 89 | end 90 | 91 | process.wait 92 | thread.join 93 | ensure 94 | r.close 95 | end 96 | ``` 97 | 98 | Note that if you just want to get the output of a command, the backtick method on Kernel may be a better fit. 99 | 100 | #### Write to stdin 101 | 102 | ```ruby 103 | process = ChildProcess.build("cat") 104 | 105 | out = Tempfile.new("duplex") 106 | out.sync = true 107 | 108 | process.io.stdout = process.io.stderr = out 109 | process.duplex = true # sets up pipe so process.io.stdin will be available after .start 110 | 111 | process.start 112 | process.io.stdin.puts "hello world" 113 | process.io.stdin.close 114 | 115 | process.poll_for_exit(exit_timeout_in_seconds) 116 | 117 | out.rewind 118 | out.read #=> "hello world\n" 119 | ``` 120 | 121 | #### Pipe output to another ChildProcess 122 | 123 | ```ruby 124 | search = ChildProcess.build("grep", '-E', %w(redis memcached).join('|')) 125 | search.duplex = true # sets up pipe so search.io.stdin will be available after .start 126 | search.io.stdout = $stdout 127 | search.start 128 | 129 | listing = ChildProcess.build("ps", "aux") 130 | listing.io.stdout = search.io.stdin 131 | listing.start 132 | listing.wait 133 | 134 | search.io.stdin.close 135 | search.wait 136 | ``` 137 | 138 | ### Ensure entire process tree dies 139 | 140 | By default, the child process does not create a new process group. This means there's no guarantee that the entire process tree will die when the child process is killed. To solve this: 141 | 142 | ```ruby 143 | process = ChildProcess.build(*args) 144 | process.leader = true 145 | process.start 146 | ``` 147 | 148 | #### Detach from parent 149 | 150 | ```ruby 151 | process = ChildProcess.build("sleep", "10") 152 | process.detach = true 153 | process.start 154 | ``` 155 | 156 | #### Invoking a shell 157 | 158 | As opposed to `Kernel#system`, `Kernel#exec` et al., ChildProcess will not automatically execute your command in a shell (like `/bin/sh` or `cmd.exe`) depending on the arguments. 159 | This means that if you try to execute e.g. gem executables (like `bundle` or `gem`) or Windows executables (with `.com` or `.bat` extensions) you may see a `ChildProcess::LaunchError`. 160 | You can work around this by being explicit about what interpreter to invoke: 161 | 162 | ```ruby 163 | ChildProcess.build("cmd.exe", "/c", "bundle") 164 | ChildProcess.build("ruby", "-S", "bundle") 165 | ``` 166 | 167 | #### Log to file 168 | 169 | Errors and debugging information are logged to `$stderr` by default but a custom logger can be used instead. 170 | 171 | ```ruby 172 | logger = Logger.new('logfile.log') 173 | logger.level = Logger::DEBUG 174 | ChildProcess.logger = logger 175 | ``` 176 | 177 | ## Caveats 178 | 179 | * With JRuby on Unix, modifying `ENV["PATH"]` before using childprocess could lead to 'Command not found' errors, since JRuby is unable to modify the environment used for PATH searches in `java.lang.ProcessBuilder`. This can be avoided by setting `ChildProcess.posix_spawn = true`. 180 | * With JRuby on Java >= 9, the JVM may need to be configured to allow JRuby to access neccessary implementations; this can be done by adding `--add-opens java.base/java.io=org.jruby.dist` and `--add-opens java.base/sun.nio.ch=org.jruby.dist` to the `JAVA_OPTS` environment variable that is used by JRuby when launching the JVM. 181 | 182 | # Implementation 183 | 184 | ChildProcess 5+ uses `Process.spawn` from the Ruby core library for maximum portability. 185 | 186 | # Note on Patches/Pull Requests 187 | 188 | 1. Fork it 189 | 2. Create your feature branch (off of the development branch) 190 | `git checkout -b my-new-feature dev` 191 | 3. Commit your changes 192 | `git commit -am 'Add some feature'` 193 | 4. Push to the branch 194 | `git push origin my-new-feature` 195 | 5. Create new Pull Request 196 | 197 | # Publishing a New Release 198 | 199 | When publishing a new gem release: 200 | 201 | 1. Ensure [latest build is green on the `dev` branch](https://travis-ci.org/enkessler/childprocess/branches) 202 | 2. Ensure [CHANGELOG](CHANGELOG.md) is updated 203 | 3. Ensure [version is bumped](lib/childprocess/version.rb) following [Semantic Versioning](https://semver.org/) 204 | 4. Merge the `dev` branch into `master`: `git checkout master && git merge dev` 205 | 5. Ensure [latest build is green on the `master` branch](https://travis-ci.org/enkessler/childprocess/branches) 206 | 6. Build gem from the green `master` branch: `git checkout master && gem build childprocess.gemspec` 207 | 7. Push gem to RubyGems: `gem push childprocess-.gem` 208 | 8. Tag commit with version, annotated with release notes: `git tag -a ` 209 | 210 | # Copyright 211 | 212 | Copyright (c) 2010-2015 Jari Bakken. See [LICENSE](LICENSE) for details. 213 | -------------------------------------------------------------------------------- /spec/childprocess_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require File.expand_path('../spec_helper', __FILE__) 4 | 5 | 6 | describe ChildProcess do 7 | 8 | here = File.dirname(__FILE__) 9 | 10 | let(:gemspec) { eval(File.read "#{here}/../childprocess.gemspec") } 11 | 12 | it 'validates cleanly' do 13 | if Gem::VERSION >= "3.5.0" 14 | expect { gemspec.validate }.not_to output(/warn/i).to_stderr 15 | else 16 | require 'rubygems/mock_gem_ui' 17 | 18 | mock_ui = Gem::MockGemUi.new 19 | Gem::DefaultUserInteraction.use_ui(mock_ui) { gemspec.validate } 20 | 21 | expect(mock_ui.error).to_not match(/warn/i) 22 | end 23 | end 24 | 25 | it "returns self when started" do 26 | process = sleeping_ruby 27 | 28 | expect(process.start).to eq process 29 | expect(process).to be_alive 30 | end 31 | 32 | it "raises ChildProcess::LaunchError if the process can't be started" do 33 | expect { invalid_process.start }.to raise_error(ChildProcess::LaunchError) 34 | end 35 | 36 | it 'raises ArgumentError if given a non-string argument' do 37 | expect { ChildProcess.build(nil, "unlikelytoexist") }.to raise_error(ArgumentError) 38 | expect { ChildProcess.build("foo", 1) }.to raise_error(ArgumentError) 39 | end 40 | 41 | it "knows if the process crashed" do 42 | process = exit_with(1).start 43 | process.wait 44 | 45 | expect(process).to be_crashed 46 | end 47 | 48 | it "knows if the process didn't crash" do 49 | process = exit_with(0).start 50 | process.wait 51 | 52 | expect(process).to_not be_crashed 53 | end 54 | 55 | it "can wait for a process to finish" do 56 | process = exit_with(0).start 57 | return_value = process.wait 58 | 59 | expect(process).to_not be_alive 60 | expect(return_value).to eq 0 61 | end 62 | 63 | it 'ignores #wait if process already finished' do 64 | process = exit_with(0).start 65 | sleep 0.01 until process.exited? 66 | 67 | expect(process.wait).to eql 0 68 | end 69 | 70 | it "escalates if TERM is ignored" do 71 | process = ignored('TERM').start 72 | process.stop 73 | expect(process).to be_exited 74 | end 75 | 76 | it "accepts a timeout argument to #stop" do 77 | process = sleeping_ruby.start 78 | process.stop(exit_timeout) 79 | end 80 | 81 | it "lets child process inherit the environment of the current process" do 82 | Tempfile.open("env-spec") do |file| 83 | file.close 84 | with_env('INHERITED' => 'yes') do 85 | process = write_env(file.path).start 86 | process.wait 87 | end 88 | 89 | file.open 90 | child_env = eval rewind_and_read(file) 91 | expect(child_env['INHERITED']).to eql 'yes' 92 | end 93 | end 94 | 95 | it "can override env vars only for the child process" do 96 | Tempfile.open("env-spec") do |file| 97 | file.close 98 | process = write_env(file.path) 99 | process.environment['CHILD_ONLY'] = '1' 100 | process.start 101 | 102 | expect(ENV['CHILD_ONLY']).to be_nil 103 | 104 | process.wait 105 | 106 | file.open 107 | child_env = eval rewind_and_read(file) 108 | expect(child_env['CHILD_ONLY']).to eql '1' 109 | end 110 | end 111 | 112 | it 'allows unicode characters in the environment' do 113 | Tempfile.open("env-spec") do |file| 114 | file.close 115 | process = write_env(file.path) 116 | process.environment['FOö'] = 'baör' 117 | process.start 118 | process.wait 119 | 120 | file.open 121 | child_env = eval rewind_and_read(file) 122 | 123 | expect(child_env['FOö']).to eql 'baör' 124 | end 125 | end 126 | 127 | it "can set env vars using Symbol keys and values" do 128 | Tempfile.open("env-spec") do |file| 129 | process = ruby('puts ENV["SYMBOL_KEY"]') 130 | process.environment[:SYMBOL_KEY] = :VALUE 131 | process.io.stdout = file 132 | process.start 133 | process.wait 134 | expect(rewind_and_read(file)).to eq "VALUE\n" 135 | end 136 | end 137 | 138 | it "raises ChildProcess::InvalidEnvironmentVariable for invalid env vars" do 139 | process = ruby(':OK') 140 | process.environment["a\0b"] = '1' 141 | expect { process.start }.to raise_error(ChildProcess::InvalidEnvironmentVariable) 142 | 143 | process = ruby(':OK') 144 | process.environment["A=1"] = '2' 145 | expect { process.start }.to raise_error(ChildProcess::InvalidEnvironmentVariable) 146 | 147 | process = ruby(':OK') 148 | process.environment['A'] = "a\0b" 149 | expect { process.start }.to raise_error(ChildProcess::InvalidEnvironmentVariable) 150 | end 151 | 152 | it "inherits the parent's env vars also when some are overridden" do 153 | Tempfile.open("env-spec") do |file| 154 | file.close 155 | with_env('INHERITED' => 'yes', 'CHILD_ONLY' => 'no') do 156 | process = write_env(file.path) 157 | process.environment['CHILD_ONLY'] = 'yes' 158 | 159 | process.start 160 | process.wait 161 | 162 | file.open 163 | child_env = eval rewind_and_read(file) 164 | 165 | expect(child_env['INHERITED']).to eq 'yes' 166 | expect(child_env['CHILD_ONLY']).to eq 'yes' 167 | end 168 | end 169 | end 170 | 171 | it "can unset env vars" do 172 | Tempfile.open("env-spec") do |file| 173 | file.close 174 | ENV['CHILDPROCESS_UNSET'] = '1' 175 | process = write_env(file.path) 176 | process.environment['CHILDPROCESS_UNSET'] = nil 177 | process.start 178 | 179 | process.wait 180 | 181 | file.open 182 | child_env = eval rewind_and_read(file) 183 | expect(child_env).to_not have_key('CHILDPROCESS_UNSET') 184 | end 185 | end 186 | 187 | it 'does not see env vars unset in parent' do 188 | Tempfile.open('env-spec') do |file| 189 | file.close 190 | ENV['CHILDPROCESS_UNSET'] = nil 191 | process = write_env(file.path) 192 | process.start 193 | 194 | process.wait 195 | 196 | file.open 197 | child_env = eval rewind_and_read(file) 198 | expect(child_env).to_not have_key('CHILDPROCESS_UNSET') 199 | end 200 | end 201 | 202 | 203 | it "passes arguments to the child" do 204 | args = ["foo", "bar"] 205 | 206 | Tempfile.open("argv-spec") do |file| 207 | process = write_argv(file.path, *args).start 208 | process.wait 209 | 210 | expect(rewind_and_read(file)).to eql args.inspect 211 | end 212 | end 213 | 214 | it "lets a detached child live on" do 215 | p_pid = nil 216 | c_pid = nil 217 | 218 | Tempfile.open('grandparent_out') do |gp_file| 219 | # Create a parent and detached child process that will spit out their PID. Make sure that the child process lasts longer than the parent. 220 | 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}\"") 221 | p_process.io.stdout = p_process.io.stderr = gp_file 222 | 223 | # Let the parent process die 224 | p_process.start 225 | p_process.wait 226 | 227 | 228 | # Gather parent and child PIDs 229 | pids = rewind_and_read(gp_file).split("\n") 230 | pids.collect! { |pid| pid[/\d+/].to_i } 231 | c_pid, p_pid = pids 232 | end 233 | 234 | # Check that the parent process has dies but the child process is still alive 235 | expect(alive?(p_pid)).to_not be true 236 | expect(alive?(c_pid)).to be true 237 | end 238 | 239 | it "preserves Dir.pwd in the child" do 240 | Tempfile.open("dir-spec-out") do |file| 241 | process = ruby("print Dir.pwd") 242 | process.io.stdout = process.io.stderr = file 243 | 244 | expected_dir = nil 245 | Dir.chdir(Dir.tmpdir) do 246 | expected_dir = Dir.pwd 247 | process.start 248 | end 249 | 250 | process.wait 251 | 252 | expect(rewind_and_read(file)).to eq expected_dir 253 | end 254 | end 255 | 256 | it "can handle whitespace, special characters and quotes in arguments" do 257 | args = ["foo bar", 'foo\bar', "'i-am-quoted'", '"i am double quoted"'] 258 | 259 | Tempfile.open("argv-spec") do |file| 260 | process = write_argv(file.path, *args).start 261 | process.wait 262 | 263 | expect(rewind_and_read(file)).to eq args.inspect 264 | end 265 | end 266 | 267 | it 'handles whitespace in the executable name' do 268 | path = File.expand_path('foo bar') 269 | 270 | with_executable_at(path) do |proc| 271 | expect(proc.start).to eq proc 272 | expect(proc).to be_alive 273 | end 274 | end 275 | 276 | it "times out when polling for exit" do 277 | process = sleeping_ruby.start 278 | expect { process.poll_for_exit(0.1) }.to raise_error(ChildProcess::TimeoutError) 279 | end 280 | 281 | it "can change working directory" do 282 | process = ruby "print Dir.pwd" 283 | 284 | with_tmpdir { |dir| 285 | process.cwd = dir 286 | 287 | orig_pwd = Dir.pwd 288 | 289 | Tempfile.open('cwd') do |file| 290 | process.io.stdout = file 291 | 292 | process.start 293 | process.wait 294 | 295 | expect(rewind_and_read(file)).to eq dir 296 | end 297 | 298 | expect(Dir.pwd).to eq orig_pwd 299 | } 300 | end 301 | 302 | it 'kills the full process tree' do 303 | Tempfile.open('kill-process-tree') do |file| 304 | process = write_pid_in_sleepy_grand_child(file.path) 305 | process.leader = true 306 | process.start 307 | 308 | pid = wait_until(30) do 309 | Integer(rewind_and_read(file)) rescue nil 310 | end 311 | 312 | process.stop 313 | expect(process).to be_exited 314 | 315 | wait_until(3) { expect(alive?(pid)).to eql(false) } 316 | end 317 | end 318 | 319 | it 'releases the GIL while waiting for the process' do 320 | time = Time.now 321 | threads = [] 322 | 323 | threads << Thread.new { sleeping_ruby(1).start.wait } 324 | threads << Thread.new(time) { expect(Time.now - time).to be < 0.5 } 325 | 326 | threads.each { |t| t.join } 327 | end 328 | 329 | it 'can check if a detached child is alive' do 330 | proc = ruby_process("-e", "sleep") 331 | proc.detach = true 332 | 333 | proc.start 334 | 335 | expect(proc).to be_alive 336 | proc.stop(0) 337 | 338 | expect(proc).to be_exited 339 | end 340 | 341 | describe 'OS detection' do 342 | 343 | before(:all) do 344 | # Save off original OS so that it can be restored later 345 | @original_host_os = RbConfig::CONFIG['host_os'] 346 | end 347 | 348 | after(:each) do 349 | # Restore things to the real OS instead of the fake test OS 350 | RbConfig::CONFIG['host_os'] = @original_host_os 351 | ChildProcess.instance_variable_set(:@os, nil) 352 | end 353 | 354 | 355 | # TODO: add tests for other OSs 356 | context 'on a BSD system' do 357 | 358 | let(:bsd_patterns) { ['bsd', 'dragonfly'] } 359 | 360 | it 'correctly identifies BSD systems' do 361 | bsd_patterns.each do |pattern| 362 | RbConfig::CONFIG['host_os'] = pattern 363 | ChildProcess.instance_variable_set(:@os, nil) 364 | 365 | expect(ChildProcess.os).to eq(:bsd) 366 | end 367 | end 368 | 369 | end 370 | 371 | end 372 | 373 | it 'has a logger' do 374 | expect(ChildProcess).to respond_to(:logger) 375 | end 376 | 377 | it 'can change its logger' do 378 | expect(ChildProcess).to respond_to(:logger=) 379 | 380 | original_logger = ChildProcess.logger 381 | begin 382 | ChildProcess.logger = :some_other_logger 383 | expect(ChildProcess.logger).to eq(:some_other_logger) 384 | ensure 385 | ChildProcess.logger = original_logger 386 | end 387 | end 388 | 389 | 390 | describe 'logger' do 391 | 392 | before(:each) do 393 | ChildProcess.logger = logger 394 | end 395 | 396 | after(:all) do 397 | ChildProcess.logger = nil 398 | end 399 | 400 | 401 | context 'with the default logger' do 402 | 403 | let(:logger) { nil } 404 | 405 | 406 | it 'logs at INFO level by default' do 407 | expect(ChildProcess.logger.level).to eq(Logger::INFO) 408 | end 409 | 410 | it 'logs at DEBUG level by default if $DEBUG is on' do 411 | original_debug = $DEBUG 412 | 413 | begin 414 | $DEBUG = true 415 | 416 | expect(ChildProcess.logger.level).to eq(Logger::DEBUG) 417 | ensure 418 | $DEBUG = original_debug 419 | end 420 | end 421 | 422 | it "logs to stderr by default" do 423 | cap = capture_std { generate_log_messages } 424 | 425 | expect(cap.stdout).to be_empty 426 | expect(cap.stderr).to_not be_empty 427 | end 428 | 429 | end 430 | 431 | context 'with a custom logger' do 432 | 433 | let(:logger) { Logger.new($stdout) } 434 | 435 | it "logs to configured logger" do 436 | cap = capture_std { generate_log_messages } 437 | 438 | expect(cap.stdout).to_not be_empty 439 | expect(cap.stderr).to be_empty 440 | end 441 | 442 | end 443 | 444 | end 445 | 446 | describe '#started?' do 447 | subject { process.started? } 448 | 449 | context 'when not started' do 450 | let(:process) { sleeping_ruby(1) } 451 | 452 | it { is_expected.to be false } 453 | end 454 | 455 | context 'when started' do 456 | let(:process) { sleeping_ruby(1).start } 457 | 458 | it { is_expected.to be true } 459 | end 460 | 461 | context 'when finished' do 462 | before(:each) { process.wait } 463 | 464 | let(:process) { sleeping_ruby(0).start } 465 | 466 | it { is_expected.to be true } 467 | end 468 | 469 | end 470 | 471 | end 472 | --------------------------------------------------------------------------------