Skip to content

Commit

Permalink
Add Process.clock_gettime support (#419)
Browse files Browse the repository at this point in the history
* Add Process.clock_gettime support

* Support nested freeze calls for monotonic clock

* Refactor to avoid parse_time side effect

* Address feedback on tests

* Smaller sleep

* Rename current to initial_time

* Use assert_operator in more places

* Update README

* Sleep between consecutive times

* Reuse TIME_EPSILON

I wonder about a better name; this is a constant that represents
enough time for Process.clock_gettime to have advanced.

* Extract variable

* Move tests for Process.clock_gettime

* Add tests for various units

* Add test for date freeze

* Fix unintended change to TimecopTest

* Revert all changes to test/timecop_test.rb
  • Loading branch information
alexcwatt authored Jun 1, 2024
1 parent aa07813 commit 3a0b567
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions lib/timecop/time_extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,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
31 changes: 31 additions & 0 deletions lib/timecop/time_stack_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions lib/timecop/timecop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
168 changes: 168 additions & 0 deletions test/timecop_with_process_clock_test.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 3a0b567

Please sign in to comment.