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
105 changes: 101 additions & 4 deletions test/timecop_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def test_scaling_returns_now_if_no_block_given
t = Time.local(2008, 10, 10, 10, 10, 10)
assert_times_effectively_equal t, Timecop.scale(4, t)
end

def test_scaling_returns_now_if_nil_supplied
assert_times_effectively_equal Time.now, Timecop.scale(nil)
end
Expand Down Expand Up @@ -421,7 +421,7 @@ def test_return_temporarily_returns_to_current_time_in_given_block
end
assert_times_effectively_equal(time_after_travel, Time.now)
end

def test_travel_returns_now_if_nil_supplied
assert_times_effectively_equal Time.now, Timecop.travel(nil)
end
Expand All @@ -433,7 +433,7 @@ def test_travel_time_with_block_returns_the_value_of_the_block

assert_equal expected, actual
end

def test_travel_raises_when_empty_string_supplied
err = assert_raises(ArgumentError) do
Timecop.travel("")
Expand Down Expand Up @@ -466,7 +466,7 @@ def test_freeze_without_params
end
end
end

def test_freeze_returns_now_if_nil_supplied
assert_times_effectively_equal Time.now, Timecop.freeze(nil)
end
Expand Down Expand Up @@ -689,8 +689,105 @@ def test_thread_safe_timecop_returns_after_block
Timecop.thread_safe = false
end

if RUBY_VERSION >= '2.1.0'
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
def test_process_clock_gettime_monotonic
Timecop.freeze do
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
assert_same monotonic, 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_nested
Timecop.freeze do
parent = monotonic

sleep(0.01)

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 monotonic, monotonic, "CLOCK_MONOTONIC is frozen"
assert_operator(monotonic, :>, initial_time, "CLOCK_MONOTONIC is not moving forward")
end

Timecop.travel(-0.5) do
refute_same monotonic, monotonic, "CLOCK_MONOTONIC is frozen"
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
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 realtime, realtime, "CLOCK_REALTIME is not frozen"
end

initial_time = realtime
Timecop.freeze(-20) do
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
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 realtime, realtime, "CLOCK_REALTIME is frozen"
assert_operator(realtime, :>, initial_time, "CLOCK_REALTIME is not moving forward")
end

Timecop.travel(Time.now - 0.1) do
refute_equal realtime, realtime, "CLOCK_REALTIME is frozen"
assert_operator(realtime, :<, initial_time, "CLOCK_REALTIME is not traveling properly")
sleep 0.1
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
end

private

if RUBY_VERSION >= '2.1.0'
Copy link

Choose a reason for hiding this comment

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

I know this is just following the convention of this codebase, so this isn't a comment on your PR per se:

Ruby 2.1.0 is 11 years old and has been EOL for 7 years.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would be nice to update the minimum Ruby version in the gemspec; right now it's at 1.9.2.

def monotonic
Process.clock_gettime(Process::CLOCK_MONOTONIC)
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
end

def realtime
Process.clock_gettime(Process::CLOCK_REALTIME)
end
end

def with_safe_mode(enabled=true)
mode = Timecop.safe_mode?
Timecop.safe_mode = enabled
Expand Down