├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── fork_break.gemspec ├── lib ├── fork_break.rb └── fork_break │ ├── breakpoint_setter.rb │ ├── breakpoints.rb │ ├── null_breakpoint_setter.rb │ ├── process.rb │ └── version.rb └── spec ├── fork_break_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Documentation: 2 | Enabled: false 3 | 4 | LineLength: 5 | Max: 120 6 | 7 | Lint/RescueException: 8 | Enabled: false 9 | 10 | SignalException: 11 | EnforcedStyle: only_raise 12 | SupportedStyles: 13 | - only_raise 14 | - only_fail 15 | - semantic 16 | 17 | Metrics/AbcSize: 18 | Max: 18 19 | 20 | Metrics/BlockLength: 21 | Exclude: 22 | - "**/*_spec.rb" 23 | 24 | Metrics/CyclomaticComplexity: 25 | Max: 7 26 | 27 | Metrics/MethodLength: 28 | Max: 15 29 | 30 | Metrics/ModuleLength: 31 | Exclude: 32 | - "**/*_spec.rb" 33 | 34 | Metrics/PerceivedComplexity: 35 | Max: 8 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.3 4 | - 2.5.1 5 | - jruby-19mode 6 | - jruby-head 7 | - ruby-head 8 | 9 | matrix: 10 | allow_failures: 11 | - rvm: jruby-19mode 12 | - rvm: jruby-head 13 | 14 | before_install: 15 | - gem update --system 16 | - gem install bundler -v 1.10.6 17 | install: 'bundle' 18 | script: 'bundle exec rake' 19 | notifications: 20 | email: 21 | - pedro.carrico@gmail.com 22 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in fork_break.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Petter Remen 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ForkBreak 2 | 3 | [![Build Status](https://secure.travis-ci.org/forkbreak/fork_break.png)](http://travis-ci.org/forkbreak/fork_break) [![Dependency Status](https://gemnasium.com/forkbreak/fork_break.png)](https://gemnasium.com/forkbreak/fork_break) [![Gem Version](https://badge.fury.io/rb/fork_break.svg)](http://badge.fury.io/rb/fork_break) [![Code Climate](https://codeclimate.com/github/forkbreak/fork_break/badges/gpa.svg)](https://codeclimate.com/github/forkbreak/fork_break) 4 | 5 | Testing multiprocess behaviour is difficult and requires a way to synchronize processes at 6 | specific execution points. This gem allows the parent process to control the behaviour of child processes using 7 | breakpoints. It was originally built for testing the behaviour of database transactions and locking mechanisms. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | gem 'fork_break' 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install fork_break 22 | 23 | ## Usage 24 | 25 | A simple example 26 | 27 | ```ruby 28 | process = ForkBreak::Process.new do |breakpoints| 29 | sleep(1) 30 | breakpoints << :after_sleep_1 31 | sleep(2) 32 | breakpoints << :after_sleep_2 33 | end 34 | 35 | def time(&block) 36 | before = Time.now 37 | block.call 38 | (Time.now - before).round 39 | end 40 | 41 | puts time { process.run_until(:after_sleep_1).wait } # => 1 42 | puts time { process.run_until(:after_sleep_2).wait } # => 2 43 | puts time { process.finish.wait } # => 0 44 | ``` 45 | 46 | You can also get access to the breakpoints by including ForkBreak::Breakpoints, allowing you to test 47 | existing classes with minor changes. The following test the behaviour of using a file as a counter, with 48 | and without file locks. 49 | 50 | ```ruby 51 | class FileCounter 52 | include ForkBreak::Breakpoints 53 | 54 | def self.open(path, use_lock = true) 55 | file = File.open(path, File::RDWR|File::CREAT, 0600) 56 | return new(file, use_lock) 57 | end 58 | 59 | def initialize(file, use_lock = true) 60 | @file = file 61 | @use_lock = use_lock 62 | end 63 | 64 | def increase 65 | 66 | breakpoints << :before_lock 67 | 68 | @file.flock(File::LOCK_EX) if @use_lock 69 | value = @file.read.to_i + 1 70 | 71 | breakpoints << :after_read 72 | 73 | @file.rewind 74 | @file.write("#{value}\n") 75 | @file.flush 76 | @file.truncate(@file.pos) 77 | end 78 | end 79 | 80 | def counter_after_synced_execution(counter_path, with_lock) 81 | process1, process2 = 2.times.map do 82 | ForkBreak::Process.new do 83 | FileCounter.open(counter_path, with_lock).increase 84 | end 85 | end 86 | 87 | process1.run_until(:after_read).wait 88 | 89 | # process2 can't wait for read since it will block 90 | process2.run_until(:before_lock).wait 91 | process2.run_until(:after_read) && sleep(0.1) 92 | 93 | process1.finish.wait # Finish process1 94 | process2.finish.wait # Finish process2 95 | 96 | File.read(counter_path).to_i 97 | end 98 | 99 | puts counter_after_synced_execution("counter_with_lock", true) # => 2 100 | puts counter_after_synced_execution("counter_without_lock", false) # => 1 101 | ``` 102 | 103 | When running outside a ForkBreak process the breakpoints will be ignored so that you can use the same classes with 104 | breakpoints in production code. 105 | 106 | There's also the possibility of adding a predefined timeout to the wait function and having it raise an exception. 107 | 108 | ```ruby 109 | process = ForkBreak::Process.new do 110 | sleep(5) 111 | end 112 | 113 | process.finish.wait(timeout: 1) # will raise ForkBreak::WaitTimeout after 1 second 114 | ``` 115 | 116 | ## Contributing 117 | 118 | 1. Fork it 119 | 2. Create your feature branch (`git checkout -b my-new-feature`) 120 | 3. Commit your changes (`git commit -am 'Added some feature'`) 121 | 4. Push to the branch (`git push origin my-new-feature`) 122 | 5. Create new Pull Request 123 | 124 | ## License 125 | ForkBreak is released under the [MIT License](http://www.opensource.org/licenses/MIT). 126 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | require 'rubocop/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | RuboCop::RakeTask.new(:rubocop) 8 | 9 | task default: %i[spec rubocop] 10 | -------------------------------------------------------------------------------- /fork_break.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'fork_break/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.authors = ['Petter Remen', 'Pedro Carriço'] 8 | gem.summary = 9 | 'Testing multiprocess behaviour is difficult and requires a way to synchronize processes at specific execution ' \ 10 | 'points. This gem allows the parent process to control the behaviour of child processes using breakpoints. It was' \ 11 | 'originally built for testing the behaviour of database transactions and locking mechanisms.' 12 | gem.description = 'Fork with breakpoints for syncing child process execution' 13 | gem.homepage = 'http://github.com/forkbreak/fork_break' 14 | gem.licenses = ['MIT'] 15 | gem.files = `git ls-files`.split($OUTPUT_RECORD_SEPARATOR) 16 | gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.name = 'fork_break' 19 | gem.require_paths = ['lib'] 20 | gem.version = ForkBreak::VERSION 21 | gem.required_ruby_version = '~> 2.2' 22 | gem.add_dependency 'fork', '= 1.0.1' 23 | gem.add_development_dependency 'rake', '~> 12.3.1' 24 | gem.add_development_dependency 'rspec', '~> 3.8.0' 25 | gem.add_development_dependency 'rubocop', '~> 0.58.2' 26 | end 27 | -------------------------------------------------------------------------------- /lib/fork_break.rb: -------------------------------------------------------------------------------- 1 | require 'fork' 2 | require 'fork_break/null_breakpoint_setter' 3 | require 'fork_break/breakpoint_setter' 4 | require 'fork_break/breakpoints' 5 | require 'fork_break/process' 6 | require 'timeout' 7 | 8 | module ForkBreak 9 | class BreakpointNotReachedError < StandardError; end 10 | class WaitTimeout < StandardError; end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fork_break/breakpoint_setter.rb: -------------------------------------------------------------------------------- 1 | module ForkBreak 2 | class BreakpointSetter 3 | def initialize(fork, debug = false) 4 | @fork = fork 5 | @next_breakpoint = :forkbreak_start 6 | @debug = debug 7 | end 8 | 9 | def <<(symbol) 10 | @fork.send_object(symbol) 11 | if symbol == @next_breakpoint 12 | @next_breakpoint = @fork.receive_object unless symbol == :forkbreak_end 13 | puts "#{@fork.pid} received #{@next_breakpoint}" if @debug 14 | end 15 | rescue EOFError => exception 16 | raise @fork.exception || exception 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/fork_break/breakpoints.rb: -------------------------------------------------------------------------------- 1 | module ForkBreak 2 | module Breakpoints 3 | def breakpoints 4 | ForkBreak::Process.breakpoint_setter 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/fork_break/null_breakpoint_setter.rb: -------------------------------------------------------------------------------- 1 | module ForkBreak 2 | class NullBreakpointSetter 3 | def <<(*) 4 | # no-op 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/fork_break/process.rb: -------------------------------------------------------------------------------- 1 | module ForkBreak 2 | class Process 3 | class << self 4 | attr_accessor :breakpoint_setter 5 | end 6 | 7 | attr_reader :return_value 8 | 9 | @breakpoint_setter = NullBreakpointSetter.new 10 | 11 | def initialize(debug = false) 12 | @debug = debug 13 | @fork = Fork.new(:return, :to_fork, :from_fork) do |child_fork| 14 | self.class.breakpoint_setter = breakpoints = BreakpointSetter.new(child_fork, debug) 15 | 16 | breakpoints << :forkbreak_start 17 | returned_value = yield(breakpoints) 18 | breakpoints << :forkbreak_end 19 | 20 | self.class.breakpoint_setter = nil 21 | returned_value 22 | end 23 | end 24 | 25 | def run_until(breakpoint) 26 | @next_breakpoint = breakpoint 27 | @fork.execute unless @fork.pid 28 | puts "Parent is sending object #{breakpoint} to #{@fork.pid}" if @debug 29 | @fork.send_object(breakpoint) 30 | self 31 | end 32 | 33 | def wait(options = {}) 34 | # A timeout value of nil will execute the block without any timeout 35 | Timeout.timeout(options[:timeout], WaitTimeout) do 36 | loop do 37 | brk = @fork.receive_object 38 | puts "Parent is receiving object #{brk} from #{@fork.pid}" if @debug 39 | 40 | @return_value = @fork.return_value if brk == :forkbreak_end 41 | 42 | return self if brk == @next_breakpoint 43 | if brk == :forkbreak_end 44 | raise BreakpointNotReachedError, "Never reached breakpoint #{@next_breakpoint.inspect}" 45 | end 46 | end 47 | end 48 | rescue EOFError => exception 49 | raise @fork.exception || exception 50 | end 51 | 52 | def finish 53 | run_until(:forkbreak_end) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/fork_break/version.rb: -------------------------------------------------------------------------------- 1 | module ForkBreak 2 | VERSION = '0.1.4'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/fork_break_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'tmpdir' 3 | 4 | describe ForkBreak::Process do 5 | it 'works as intented' do 6 | Dir.mktmpdir do |tmpdir| 7 | first_file = File.join(tmpdir, 'first_file') 8 | second_file = File.join(tmpdir, 'second_file') 9 | process = ForkBreak::Process.new do |breakpoints| 10 | FileUtils.touch(first_file) 11 | breakpoints << :after_first_file 12 | FileUtils.touch(second_file) 13 | end 14 | expect(File.exist?(first_file)).to be_falsey 15 | expect(File.exist?(second_file)).to be_falsey 16 | 17 | process.run_until(:after_first_file).wait 18 | expect(File.exist?(first_file)).to be_truthy 19 | expect(File.exist?(second_file)).to be_falsey 20 | 21 | process.finish.wait 22 | expect(File.exist?(first_file)).to be_truthy 23 | expect(File.exist?(second_file)).to be_truthy 24 | end 25 | end 26 | 27 | it 'raises an error (on wait) if a breakpoint is not encountered' do 28 | foo = ForkBreak::Process.new do |_breakpoints| 29 | # noop 30 | end 31 | expect do 32 | foo.run_until(:will_not_run).wait 33 | end.to raise_error(ForkBreak::BreakpointNotReachedError) 34 | end 35 | 36 | it 'works for the documentation example' do 37 | class FileCounter 38 | include ForkBreak::Breakpoints 39 | 40 | def self.open(path, use_lock = true) 41 | file = File.open(path, File::RDWR | File::CREAT, 0o600) 42 | new(file, use_lock) 43 | end 44 | 45 | def initialize(file, use_lock = true) 46 | @file = file 47 | @use_lock = use_lock 48 | end 49 | 50 | def increase 51 | breakpoints << :before_lock 52 | @file.flock(File::LOCK_EX) if @use_lock 53 | value = @file.read.to_i + 1 54 | breakpoints << :after_read 55 | @file.rewind 56 | @file.write("#{value}\n") 57 | @file.flush 58 | @file.truncate(@file.pos) 59 | end 60 | end 61 | 62 | def counter_after_synced_execution(counter_path, with_lock) 63 | process1, process2 = Array.new(2) do 64 | ForkBreak::Process.new { FileCounter.open(counter_path, with_lock).increase } 65 | end 66 | 67 | process1.run_until(:after_read).wait 68 | 69 | # process2 can't wait for read since it will block 70 | process2.run_until(:before_lock).wait 71 | process2.run_until(:after_read) && sleep(0.1) 72 | 73 | process1.finish.wait # Finish process1 74 | process2.finish.wait # Finish process2 75 | 76 | File.read(counter_path).to_i 77 | end 78 | 79 | Dir.mktmpdir do |tmpdir| 80 | counter_path = File.join(tmpdir, 'counter') 81 | 82 | expect(counter_after_synced_execution(counter_path, true)).to eq(2) 83 | 84 | File.unlink(counter_path) 85 | expect(counter_after_synced_execution(counter_path, false)).to eq(1) 86 | end 87 | end 88 | 89 | it 'ignores breakpoints when running outside a ForkBreak process' do 90 | class Foo 91 | include ForkBreak::Breakpoints 92 | 93 | def bar 94 | breakpoints << :test 95 | 'baz' 96 | end 97 | end 98 | 99 | expect(Foo.new.breakpoints).to be_kind_of(ForkBreak::NullBreakpointSetter) 100 | expect { Foo.new.bar }.to_not raise_error 101 | expect(Foo.new.bar).to eq('baz') 102 | end 103 | 104 | it 'raises the process exception' do 105 | class MyException < StandardError; end 106 | 107 | process = ForkBreak::Process.new do 108 | raise MyException 109 | end 110 | 111 | expect { process.finish.wait }.to raise_error(MyException) 112 | end 113 | 114 | it 'raises a wait timeout eror when the process takes longer than the specified wait timeout' do 115 | process = ForkBreak::Process.new do 116 | sleep(1) 117 | end 118 | 119 | expect { process.finish.wait(timeout: 0.01) }.to raise_error(ForkBreak::WaitTimeout) 120 | end 121 | 122 | it 'keeps the return value of the process' do 123 | class Foo 124 | include ForkBreak::Breakpoints 125 | 126 | def bar 127 | 'baz' 128 | end 129 | end 130 | 131 | process = ForkBreak::Process.new { Foo.new.bar }.finish.wait 132 | 133 | expect(process.return_value).to eq('baz') 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/fork_break' 2 | 3 | RSpec.configure do |config| 4 | # Run specs in random order to surface order dependencies. If you find an 5 | # order dependency and want to debug it, you can fix the order by providing 6 | # the seed, which is printed after each run. 7 | # --seed 1234 8 | config.order = 'random' 9 | end 10 | --------------------------------------------------------------------------------