diff --git a/README.markdown b/README.markdown index d1d3da6..dc197a1 100644 --- a/README.markdown +++ b/README.markdown @@ -5,7 +5,7 @@ ## DESCRIPTION -A gem providing "time travel" and "time freezing" capabilities, making it dead simple to test time-dependent code. It provides a unified method to mock `Time.now`, `Date.today`, and `DateTime.now` in a single call. +A gem providing "time travel" and "time freezing" capabilities, making it dead simple to test time-dependent code. It provides a unified method to mock `Time.now`, `Date.today`, `DateTime.now`, and `Process.clock_gettime` in a single call. ## INSTALL diff --git a/lib/timecop/time_extensions.rb b/lib/timecop/time_extensions.rb index 3250c45..98e6150 100644 --- a/lib/timecop/time_extensions.rb +++ b/lib/timecop/time_extensions.rb @@ -158,3 +158,59 @@ def mocked_time_stack_item end end end + +if RUBY_VERSION >= '2.1.0' + module Process #:nodoc: + class << self + alias_method :clock_gettime_without_mock, :clock_gettime + + def clock_gettime_mock_time(clock_id, unit = :float_second) + mock_time = case clock_id + when Process::CLOCK_MONOTONIC + mock_time_monotonic + when Process::CLOCK_REALTIME + mock_time_realtime + end + + return clock_gettime_without_mock(clock_id, unit) unless mock_time + + divisor = case unit + when :float_second + 1_000_000_000.0 + when :second + 1_000_000_000 + when :float_millisecond + 1_000_000.0 + when :millisecond + 1_000_000 + when :float_microsecond + 1000.0 + when :microsecond + 1000 + when :nanosecond + 1 + end + + (mock_time / divisor) + end + + alias_method :clock_gettime, :clock_gettime_mock_time + + private + + def mock_time_monotonic + mocked_time_stack_item = Timecop.top_stack_item + mocked_time_stack_item.nil? ? nil : mocked_time_stack_item.monotonic + end + + def mock_time_realtime + mocked_time_stack_item = Timecop.top_stack_item + + return nil if mocked_time_stack_item.nil? + + t = mocked_time_stack_item.time + t.to_i * 1_000_000_000 + t.nsec + end + end + end +end diff --git a/lib/timecop/time_stack_item.rb b/lib/timecop/time_stack_item.rb index 875bcb6..ef49150 100644 --- a/lib/timecop/time_stack_item.rb +++ b/lib/timecop/time_stack_item.rb @@ -9,6 +9,7 @@ def initialize(mock_type, *args) @travel_offset = @scaling_factor = nil @scaling_factor = args.shift if mock_type == :scale @mock_type = mock_type + @monotonic = parse_monotonic_time(*args) if RUBY_VERSION >= '2.1.0' @time = parse_time(*args) @time_was = Time.now_without_mock_time @travel_offset = compute_travel_offset @@ -54,6 +55,26 @@ def scaling_factor @scaling_factor end + if RUBY_VERSION >= '2.1.0' + def monotonic + if travel_offset.nil? + @monotonic + elsif scaling_factor.nil? + current_monotonic + travel_offset * (10 ** 9) + else + (@monotonic + (current_monotonic - @monotonic) * scaling_factor).to_i + end + end + + def current_monotonic + Process.clock_gettime_without_mock(Process::CLOCK_MONOTONIC, :nanosecond) + end + + def current_monotonic_with_mock + Process.clock_gettime_mock_time(Process::CLOCK_MONOTONIC, :nanosecond) + end + end + def time(time_klass = Time) #:nodoc: if @time.respond_to?(:in_time_zone) time = time_klass.at(@time.dup.localtime) @@ -97,6 +118,16 @@ def utc_offset_to_rational(utc_offset) Rational(utc_offset, 24 * 60 * 60) end + def parse_monotonic_time(*args) + arg = args.shift + offset_in_nanoseconds = if args.empty? && (arg.kind_of?(Integer) || arg.kind_of?(Float)) + arg * 1_000_000_000 + else + 0 + end + current_monotonic_with_mock + offset_in_nanoseconds + end + def parse_time(*args) arg = args.shift if arg.is_a?(Time) diff --git a/lib/timecop/timecop.rb b/lib/timecop/timecop.rb index 6fffe61..da4d7cd 100644 --- a/lib/timecop/timecop.rb +++ b/lib/timecop/timecop.rb @@ -38,6 +38,12 @@ class << self # previous values after the block has finished executing. This allows us to nest multiple # calls to Timecop.travel and have each block maintain it's concept of "now." # + # The Process.clock_gettime call mocks both CLOCK::MONOTIC and CLOCK::REALTIME + # + # CLOCK::MONOTONIC works slightly differently than other clocks. This clock cannot move to a + # particular date/time. So the only option that changes this clock is #4 which will move the + # clock the requested offset. Otherwise the clock is frozen to the current tick. + # # * Note: Timecop.freeze will actually freeze time. This can cause unanticipated problems if # benchmark or other timing calls are executed, which implicitly expect Time to actually move # forward. diff --git a/test/timecop_with_process_clock_test.rb b/test/timecop_with_process_clock_test.rb new file mode 100644 index 0000000..d1738cf --- /dev/null +++ b/test/timecop_with_process_clock_test.rb @@ -0,0 +1,168 @@ +require_relative "test_helper" +require 'timecop' + +class TestTimecopWithProcessClock < Minitest::Test + TIME_EPSILON = 0.001 # seconds - represents enough time for Process.clock_gettime to have advanced if not frozen + + def teardown + Timecop.return + end + + if RUBY_VERSION >= '2.1.0' + def test_process_clock_gettime_monotonic + Timecop.freeze do + assert_same(*consecutive_monotonic, "CLOCK_MONOTONIC is not frozen") + end + + initial_time = monotonic + Timecop.freeze(-0.5) do + assert_operator(monotonic, :<, initial_time, "CLOCK_MONOTONIC is not traveling back in time") + end + end + + def test_process_clock_gettime_monotonic_with_date_freeze + date = Date.new(2024, 6, 1) + monotonic1 = Timecop.freeze(date) { monotonic } + monotonic2 = Timecop.freeze(date) { monotonic } + + refute_equal(monotonic1, monotonic2, "CLOCK_MONOTONIC is not expected to freeze deterministically with a date") + end + + def test_process_clock_gettime_realtime_with_date_freeze + date = Date.new(2024, 6, 1) + realtime_1 = Timecop.freeze(date) { realtime } + realtime_2 = Timecop.freeze(date) { realtime } + + assert_equal(realtime_1, realtime_2, "CLOCK_REALTIME is expected to support freezing with a date") + end + + def test_process_clock_gettime_units_integer + Timecop.freeze do + time_in_nanoseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + time_in_microseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) + time_in_milliseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + time_in_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) + + assert_equal(time_in_microseconds, (time_in_nanoseconds / 10**3).to_i) + assert_equal(time_in_milliseconds, (time_in_nanoseconds / 10**6).to_i) + assert_equal(time_in_seconds, (time_in_nanoseconds / 10**9).to_i) + end + end + + def test_process_clock_gettime_units_float + Timecop.freeze do + time_in_nanoseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).to_f + + float_microseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_microsecond) + float_milliseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) + float_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) + + delta = 0.000001 + assert_in_delta(float_microseconds, time_in_nanoseconds / 10**3, delta) + assert_in_delta(float_milliseconds, time_in_nanoseconds / 10**6, delta) + assert_in_delta(float_seconds, time_in_nanoseconds / 10**9, delta) + end + end + + def test_process_clock_gettime_monotonic_nested + Timecop.freeze do + parent = monotonic + + sleep(TIME_EPSILON) + + delta = 0.5 + Timecop.freeze(delta) do + child = monotonic + assert_equal(child, parent + delta, "Nested freeze not working for monotonic time") + end + end + end + + def test_process_clock_gettime_monotonic_travel + initial_time = monotonic + Timecop.travel do + refute_same(*consecutive_monotonic, "CLOCK_MONOTONIC is frozen") + assert_operator(monotonic, :>, initial_time, "CLOCK_MONOTONIC is not moving forward") + end + + Timecop.travel(-0.5) do + refute_same(*consecutive_monotonic, "CLOCK_MONOTONIC is frozen") + assert_operator(monotonic, :<, initial_time, "CLOCK_MONOTONIC is not traveling properly") + end + end + + def test_process_clock_gettime_monotonic_scale + scale = 4 + sleep_length = 0.25 + Timecop.scale(scale) do + initial_time = monotonic + sleep(sleep_length) + expected_time = initial_time + (scale * sleep_length) + assert_times_effectively_equal expected_time, monotonic, 0.1, "CLOCK_MONOTONIC is not scaling" + end + end + + def test_process_clock_gettime_realtime + Timecop.freeze do + assert_same(*consecutive_realtime, "CLOCK_REALTIME is not frozen") + end + + initial_time = realtime + Timecop.freeze(-20) do + assert_operator(realtime, :<, initial_time, "CLOCK_REALTIME is not traveling back in time") + end + end + + def test_process_clock_gettime_realtime_travel + initial_time = realtime + Timecop.travel do + refute_equal consecutive_realtime, "CLOCK_REALTIME is frozen" + assert_operator(realtime, :>, initial_time, "CLOCK_REALTIME is not moving forward") + end + + delta = 0.1 + Timecop.travel(Time.now - delta) do + refute_equal consecutive_realtime, "CLOCK_REALTIME is frozen" + assert_operator(realtime, :<, initial_time, "CLOCK_REALTIME is not traveling properly") + sleep(delta) + assert_operator(realtime, :>, initial_time, "CLOCK_REALTIME is not traveling properly") + end + end + + def test_process_clock_gettime_realtime_scale + scale = 4 + sleep_length = 0.25 + Timecop.scale(scale) do + initial_time = realtime + sleep(sleep_length) + assert_operator(initial_time + scale * sleep_length, :<, realtime, "CLOCK_REALTIME is not scaling") + end + end + + private + + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def realtime + Process.clock_gettime(Process::CLOCK_REALTIME) + end + + def consecutive_monotonic + consecutive_times(:monotonic) + end + + def consecutive_realtime + consecutive_times(:realtime) + end + + def consecutive_times(time_method) + t1 = send(time_method) + sleep(TIME_EPSILON) + t2 = send(time_method) + + [t1, t2] + end + end +end