Skip to content

Commit 3a0b567

Browse files
authored
Add Process.clock_gettime support (#419)
* 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
1 parent aa07813 commit 3a0b567

File tree

5 files changed

+262
-1
lines changed

5 files changed

+262
-1
lines changed

README.markdown

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
## DESCRIPTION
77

8-
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.
8+
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.
99

1010
## INSTALL
1111

lib/timecop/time_extensions.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,59 @@ def mocked_time_stack_item
167167
end
168168
end
169169
end
170+
171+
if RUBY_VERSION >= '2.1.0'
172+
module Process #:nodoc:
173+
class << self
174+
alias_method :clock_gettime_without_mock, :clock_gettime
175+
176+
def clock_gettime_mock_time(clock_id, unit = :float_second)
177+
mock_time = case clock_id
178+
when Process::CLOCK_MONOTONIC
179+
mock_time_monotonic
180+
when Process::CLOCK_REALTIME
181+
mock_time_realtime
182+
end
183+
184+
return clock_gettime_without_mock(clock_id, unit) unless mock_time
185+
186+
divisor = case unit
187+
when :float_second
188+
1_000_000_000.0
189+
when :second
190+
1_000_000_000
191+
when :float_millisecond
192+
1_000_000.0
193+
when :millisecond
194+
1_000_000
195+
when :float_microsecond
196+
1000.0
197+
when :microsecond
198+
1000
199+
when :nanosecond
200+
1
201+
end
202+
203+
(mock_time / divisor)
204+
end
205+
206+
alias_method :clock_gettime, :clock_gettime_mock_time
207+
208+
private
209+
210+
def mock_time_monotonic
211+
mocked_time_stack_item = Timecop.top_stack_item
212+
mocked_time_stack_item.nil? ? nil : mocked_time_stack_item.monotonic
213+
end
214+
215+
def mock_time_realtime
216+
mocked_time_stack_item = Timecop.top_stack_item
217+
218+
return nil if mocked_time_stack_item.nil?
219+
220+
t = mocked_time_stack_item.time
221+
t.to_i * 1_000_000_000 + t.nsec
222+
end
223+
end
224+
end
225+
end

lib/timecop/time_stack_item.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def initialize(mock_type, *args)
99
@travel_offset = @scaling_factor = nil
1010
@scaling_factor = args.shift if mock_type == :scale
1111
@mock_type = mock_type
12+
@monotonic = parse_monotonic_time(*args) if RUBY_VERSION >= '2.1.0'
1213
@time = parse_time(*args)
1314
@time_was = Time.now_without_mock_time
1415
@travel_offset = compute_travel_offset
@@ -54,6 +55,26 @@ def scaling_factor
5455
@scaling_factor
5556
end
5657

58+
if RUBY_VERSION >= '2.1.0'
59+
def monotonic
60+
if travel_offset.nil?
61+
@monotonic
62+
elsif scaling_factor.nil?
63+
current_monotonic + travel_offset * (10 ** 9)
64+
else
65+
(@monotonic + (current_monotonic - @monotonic) * scaling_factor).to_i
66+
end
67+
end
68+
69+
def current_monotonic
70+
Process.clock_gettime_without_mock(Process::CLOCK_MONOTONIC, :nanosecond)
71+
end
72+
73+
def current_monotonic_with_mock
74+
Process.clock_gettime_mock_time(Process::CLOCK_MONOTONIC, :nanosecond)
75+
end
76+
end
77+
5778
def time(time_klass = Time) #:nodoc:
5879
if @time.respond_to?(:in_time_zone)
5980
time = time_klass.at(@time.dup.localtime)
@@ -97,6 +118,16 @@ def utc_offset_to_rational(utc_offset)
97118
Rational(utc_offset, 24 * 60 * 60)
98119
end
99120

121+
def parse_monotonic_time(*args)
122+
arg = args.shift
123+
offset_in_nanoseconds = if args.empty? && (arg.kind_of?(Integer) || arg.kind_of?(Float))
124+
arg * 1_000_000_000
125+
else
126+
0
127+
end
128+
current_monotonic_with_mock + offset_in_nanoseconds
129+
end
130+
100131
def parse_time(*args)
101132
arg = args.shift
102133
if arg.is_a?(Time)

lib/timecop/timecop.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ class << self
3838
# previous values after the block has finished executing. This allows us to nest multiple
3939
# calls to Timecop.travel and have each block maintain it's concept of "now."
4040
#
41+
# The Process.clock_gettime call mocks both CLOCK::MONOTIC and CLOCK::REALTIME
42+
#
43+
# CLOCK::MONOTONIC works slightly differently than other clocks. This clock cannot move to a
44+
# particular date/time. So the only option that changes this clock is #4 which will move the
45+
# clock the requested offset. Otherwise the clock is frozen to the current tick.
46+
#
4147
# * Note: Timecop.freeze will actually freeze time. This can cause unanticipated problems if
4248
# benchmark or other timing calls are executed, which implicitly expect Time to actually move
4349
# forward.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
require_relative "test_helper"
2+
require 'timecop'
3+
4+
class TestTimecopWithProcessClock < Minitest::Test
5+
TIME_EPSILON = 0.001 # seconds - represents enough time for Process.clock_gettime to have advanced if not frozen
6+
7+
def teardown
8+
Timecop.return
9+
end
10+
11+
if RUBY_VERSION >= '2.1.0'
12+
def test_process_clock_gettime_monotonic
13+
Timecop.freeze do
14+
assert_same(*consecutive_monotonic, "CLOCK_MONOTONIC is not frozen")
15+
end
16+
17+
initial_time = monotonic
18+
Timecop.freeze(-0.5) do
19+
assert_operator(monotonic, :<, initial_time, "CLOCK_MONOTONIC is not traveling back in time")
20+
end
21+
end
22+
23+
def test_process_clock_gettime_monotonic_with_date_freeze
24+
date = Date.new(2024, 6, 1)
25+
monotonic1 = Timecop.freeze(date) { monotonic }
26+
monotonic2 = Timecop.freeze(date) { monotonic }
27+
28+
refute_equal(monotonic1, monotonic2, "CLOCK_MONOTONIC is not expected to freeze deterministically with a date")
29+
end
30+
31+
def test_process_clock_gettime_realtime_with_date_freeze
32+
date = Date.new(2024, 6, 1)
33+
realtime_1 = Timecop.freeze(date) { realtime }
34+
realtime_2 = Timecop.freeze(date) { realtime }
35+
36+
assert_equal(realtime_1, realtime_2, "CLOCK_REALTIME is expected to support freezing with a date")
37+
end
38+
39+
def test_process_clock_gettime_units_integer
40+
Timecop.freeze do
41+
time_in_nanoseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
42+
time_in_microseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
43+
time_in_milliseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
44+
time_in_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
45+
46+
assert_equal(time_in_microseconds, (time_in_nanoseconds / 10**3).to_i)
47+
assert_equal(time_in_milliseconds, (time_in_nanoseconds / 10**6).to_i)
48+
assert_equal(time_in_seconds, (time_in_nanoseconds / 10**9).to_i)
49+
end
50+
end
51+
52+
def test_process_clock_gettime_units_float
53+
Timecop.freeze do
54+
time_in_nanoseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).to_f
55+
56+
float_microseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_microsecond)
57+
float_milliseconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
58+
float_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second)
59+
60+
delta = 0.000001
61+
assert_in_delta(float_microseconds, time_in_nanoseconds / 10**3, delta)
62+
assert_in_delta(float_milliseconds, time_in_nanoseconds / 10**6, delta)
63+
assert_in_delta(float_seconds, time_in_nanoseconds / 10**9, delta)
64+
end
65+
end
66+
67+
def test_process_clock_gettime_monotonic_nested
68+
Timecop.freeze do
69+
parent = monotonic
70+
71+
sleep(TIME_EPSILON)
72+
73+
delta = 0.5
74+
Timecop.freeze(delta) do
75+
child = monotonic
76+
assert_equal(child, parent + delta, "Nested freeze not working for monotonic time")
77+
end
78+
end
79+
end
80+
81+
def test_process_clock_gettime_monotonic_travel
82+
initial_time = monotonic
83+
Timecop.travel do
84+
refute_same(*consecutive_monotonic, "CLOCK_MONOTONIC is frozen")
85+
assert_operator(monotonic, :>, initial_time, "CLOCK_MONOTONIC is not moving forward")
86+
end
87+
88+
Timecop.travel(-0.5) do
89+
refute_same(*consecutive_monotonic, "CLOCK_MONOTONIC is frozen")
90+
assert_operator(monotonic, :<, initial_time, "CLOCK_MONOTONIC is not traveling properly")
91+
end
92+
end
93+
94+
def test_process_clock_gettime_monotonic_scale
95+
scale = 4
96+
sleep_length = 0.25
97+
Timecop.scale(scale) do
98+
initial_time = monotonic
99+
sleep(sleep_length)
100+
expected_time = initial_time + (scale * sleep_length)
101+
assert_times_effectively_equal expected_time, monotonic, 0.1, "CLOCK_MONOTONIC is not scaling"
102+
end
103+
end
104+
105+
def test_process_clock_gettime_realtime
106+
Timecop.freeze do
107+
assert_same(*consecutive_realtime, "CLOCK_REALTIME is not frozen")
108+
end
109+
110+
initial_time = realtime
111+
Timecop.freeze(-20) do
112+
assert_operator(realtime, :<, initial_time, "CLOCK_REALTIME is not traveling back in time")
113+
end
114+
end
115+
116+
def test_process_clock_gettime_realtime_travel
117+
initial_time = realtime
118+
Timecop.travel do
119+
refute_equal consecutive_realtime, "CLOCK_REALTIME is frozen"
120+
assert_operator(realtime, :>, initial_time, "CLOCK_REALTIME is not moving forward")
121+
end
122+
123+
delta = 0.1
124+
Timecop.travel(Time.now - delta) do
125+
refute_equal consecutive_realtime, "CLOCK_REALTIME is frozen"
126+
assert_operator(realtime, :<, initial_time, "CLOCK_REALTIME is not traveling properly")
127+
sleep(delta)
128+
assert_operator(realtime, :>, initial_time, "CLOCK_REALTIME is not traveling properly")
129+
end
130+
end
131+
132+
def test_process_clock_gettime_realtime_scale
133+
scale = 4
134+
sleep_length = 0.25
135+
Timecop.scale(scale) do
136+
initial_time = realtime
137+
sleep(sleep_length)
138+
assert_operator(initial_time + scale * sleep_length, :<, realtime, "CLOCK_REALTIME is not scaling")
139+
end
140+
end
141+
142+
private
143+
144+
def monotonic
145+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
146+
end
147+
148+
def realtime
149+
Process.clock_gettime(Process::CLOCK_REALTIME)
150+
end
151+
152+
def consecutive_monotonic
153+
consecutive_times(:monotonic)
154+
end
155+
156+
def consecutive_realtime
157+
consecutive_times(:realtime)
158+
end
159+
160+
def consecutive_times(time_method)
161+
t1 = send(time_method)
162+
sleep(TIME_EPSILON)
163+
t2 = send(time_method)
164+
165+
[t1, t2]
166+
end
167+
end
168+
end

0 commit comments

Comments
 (0)