Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Process.clock_gettime support #419

Merged
Merged
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 @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about Process::CLOCK_THREAD_CPUTIME_ID?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I could see adding that one too. I will focus on the bug fix first and then see if I can include this scope in the PR; if not, it should be a simpler follow-up after this lands.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to other ideas for this constant name to better represent the meaning that I'm currently explaining with the comment.


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