class ThreadWeaver::IterativeRaceDetector
Public Class Methods
new(setup:, run:, check:, target_classes:, assume_deadlocked_after_ms:, run_secondary: nil, expect_nonblocking: false)
click to toggle source
# File lib/thread_weaver/iterative_race_detector.rb, line 30 def initialize(setup:, run:, check:, target_classes:, assume_deadlocked_after_ms:, run_secondary: nil, expect_nonblocking: false) @setup_blk = T.let(setup, T.proc.returns(T.untyped)) @check_blk = T.let(check, T.proc.params(arg0: T.untyped).returns(T::Boolean)) @target_classes = T.let(target_classes, T::Array[Module]) @expect_nonblocking = T.let(expect_nonblocking, T::Boolean) @run_blk = T.let(run, T.proc.params(arg0: T.untyped).void) # Secondary is optional as the common case will be testing two identical blocks of code @run_secondary_blk = T.let(run_secondary || run, T.proc.params(arg0: T.untyped).void) @assume_deadlocked_after = T.let(assume_deadlocked_after_ms / 1000.0, Float) @scenarios_run = T.let(0, Integer) @secondary_deadlocked_count = T.let(0, Integer) end
Public Instance Methods
check_if_can_run_standalone(blk, name)
click to toggle source
# File lib/thread_weaver/iterative_race_detector.rb, line 143 def check_if_can_run_standalone(blk, name) context = @setup_blk.call thread = ControllableThread.new(context, name: name, &blk) thread.set_next_instruction(ContinueToThreadEnd.new) if check_for_deadlock(thread) thread.kill message = "#{name} appears to be deadlocked when running alone, with no other concurrent "\ "threads. #{DEADLOCK_MIGHT_BE_CONFIG}" raise DeadlockDetectedError.new(message) end check_passed = @check_blk.call(context) unless check_passed message = "#{name} failed check when running alone, with no other concurrent threads. "\ "Your check logic may be flawed." raise RaceConditionDetectedError.new(message) end end
run()
click to toggle source
# File lib/thread_weaver/iterative_race_detector.rb, line 47 def run check_if_can_run_standalone(@run_blk, "run") if @run_secondary_blk != @run_blk check_if_can_run_standalone(@run_secondary_blk, "run_secondary") end hold_primary_at_line_count = -1 done = T.let(false, T::Boolean) error_encountered = T.let(nil, T.nilable(Exception)) until done Timeout.timeout(2 * @assume_deadlocked_after) do hold_primary_at_line_count += 1 context = @setup_blk.call primary_thread = ControllableThread.new(context, name: "primary_thread", &@run_blk) # Pause the primary thread after it executes hold_primary_at_line_count number of lines primary_thread.set_and_wait_for_next_instruction( PauseWhenLineCount.new( count: hold_primary_at_line_count, target_classes: @target_classes ) ) # Start secondary thread and instruct it to run until completion. The primary thread is # still paused part-way through its execution secondary_thread = ControllableThread.new(context, name: "secondary_thread", &@run_secondary_blk) secondary_thread.set_next_instruction(ContinueToThreadEnd.new) if check_for_deadlock(secondary_thread) # At this point, it appears that the secondary thread is deadlocked. This could be # because of a true deadlock, but this also is expected to happen even in thread-safe # code that uses blocking locks. If blocking locks are used then its quite likely that # pausing the primary thread at certain points might would block the secondary thread. # For that reason, this isn't considered an outright error. @secondary_deadlocked_count += 1 primary_thread.set_and_wait_for_next_instruction(ContinueToThreadEnd.new) if @expect_nonblocking error_encountered ||= BlockingSynchronizationDetected.new( "Deadlock detected, but expect_nonblocking was set to true. Make sure you aren't "\ "blocking waiting for a lock." ) end end # Wait for the secondary thread to complete, taking note of any errors begin secondary_thread.join rescue => e # Defer exception until after the primary thread gets a chance to join, to avoid leaking # threads error_encountered ||= e end # Only now that the secondary thread has completed, release the primary thread primary_thread.set_and_wait_for_next_instruction(ContinueToThreadEnd.new) begin primary_thread.join @scenarios_run += 1 rescue ThreadCompletedEarlyError # ThreadCompletedEarlyError will occur if the primary thread never successfully paused # at the specified location. This happens normally, when hold_primary_at_line_count is # incremented until it exceeds the number of lines actually executed in the primary # thread. Once that point is reached, the primary thread will never get caught on the # PauseWhenLineCount instruction. ControllableThread signals failures to execute a given # instruction by returning a ThreadCompletedEarlyError, which we use as a signal to stop # probing for race conditions. When this happens, we have done a race condition check # with the primary thread paused at every possible location in target_classes, assuming # the code in question is deterministic, so there is nothing left to check. done = true end # Now that both threads have had a chance to join, raise any errors discovered raise error_encountered if error_encountered check_passed = @check_blk.call(context) unless check_passed raise RaceConditionDetectedError.new("Test failed") end end end if @secondary_deadlocked_count == @scenarios_run message = "In every scenario, the secondary thread was assumed deadlocked while the "\ "primary thread was paused. #{DEADLOCK_MIGHT_BE_CONFIG}" raise DeadlockDetectedError.new(message) end end
Private Instance Methods
check_for_deadlock(thread)
click to toggle source
# File lib/thread_weaver/iterative_race_detector.rb, line 168 def check_for_deadlock(thread) started_at = Time.now while thread.alive? && (Time.now - started_at) < @assume_deadlocked_after Thread.pass end thread.alive? end