Skip to content

Commit 8e0de0d

Browse files
committed
Test(fix[retry]): Measure the budget monotonically
why: retry_until timed its budget with time.time(), a wall clock that steps. An NTP correction, a container clock sync, or a VM resume landing inside a wait ends it early or stretches it by the size of the step -- the timeout fires against a duration that never elapsed. This is not test-only: retry_until is the wait inside ControlMode.__enter__ and the pytest plugin. Refs #726. what: - Time the budget with time.monotonic(), which only moves forward - Time the tests' own bound assertions the same way - Add a regression test stepping time.time() forward an hour mid-wait, which fails against the wall clock and passes against monotonic
1 parent 908061c commit 8e0de0d

2 files changed

Lines changed: 50 additions & 13 deletions

File tree

src/libtmux/test/retry.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ def retry_until(
7070
raises : bool
7171
Whether or not to raise an exception on timeout. Defaults to ``True``.
7272
73+
Notes
74+
-----
75+
The budget is measured with :func:`time.monotonic`, which only ever moves
76+
forward. A wall clock does not: an NTP correction, a container clock sync,
77+
or a VM resume steps it, and a step landing inside the wait would end it
78+
early or stretch it by the size of the step.
79+
7380
Examples
7481
--------
7582
>>> def fn():
@@ -100,10 +107,10 @@ def retry_until(
100107
... args=(pane,),
101108
... )
102109
"""
103-
ini = time.time()
110+
ini = time.monotonic()
104111

105112
while not fun(*args):
106-
end = time.time()
113+
end = time.monotonic()
107114
if end - ini >= seconds:
108115
if raises:
109116
raise WaitTimeout

tests/test/test_retry.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import typing as t
6-
from time import sleep, time
6+
from time import monotonic, sleep
77

88
import pytest
99

@@ -16,7 +16,7 @@
1616

1717
def test_retry_three_times() -> None:
1818
"""Test retry_until()."""
19-
ini = time()
19+
ini = monotonic()
2020
value = 0
2121

2222
def call_me_three_times() -> bool:
@@ -31,14 +31,14 @@ def call_me_three_times() -> bool:
3131

3232
retry_until(call_me_three_times, 1)
3333

34-
end = time()
34+
end = monotonic()
3535

3636
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
3737

3838

3939
def test_function_times_out() -> None:
4040
"""Test time outs with retry_until()."""
41-
ini = time()
41+
ini = monotonic()
4242

4343
def never_true() -> bool:
4444
sleep(
@@ -49,14 +49,14 @@ def never_true() -> bool:
4949
with pytest.raises(exc.WaitTimeout):
5050
retry_until(never_true, 1)
5151

52-
end = time()
52+
end = monotonic()
5353

5454
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
5555

5656

5757
def test_function_times_out_no_raise() -> None:
5858
"""Tests retry_until() with exception raising disabled."""
59-
ini = time()
59+
ini = monotonic()
6060

6161
def never_true() -> bool:
6262
sleep(
@@ -66,13 +66,13 @@ def never_true() -> bool:
6666

6767
retry_until(never_true, 1, raises=False)
6868

69-
end = time()
69+
end = monotonic()
7070
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
7171

7272

7373
def test_function_times_out_no_raise_assert() -> None:
7474
"""Tests retry_until() with exception raising disabled, returning False."""
75-
ini = time()
75+
ini = monotonic()
7676

7777
def never_true() -> bool:
7878
sleep(
@@ -82,13 +82,13 @@ def never_true() -> bool:
8282

8383
assert not retry_until(never_true, 1, raises=False)
8484

85-
end = time()
85+
end = monotonic()
8686
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
8787

8888

8989
def test_retry_three_times_no_raise_assert() -> None:
9090
"""Tests retry_until() with exception raising disabled, with closure variable."""
91-
ini = time()
91+
ini = monotonic()
9292
value = 0
9393

9494
def call_me_three_times() -> bool:
@@ -105,7 +105,7 @@ def call_me_three_times() -> bool:
105105

106106
assert retry_until(call_me_three_times, 1, raises=False)
107107

108-
end = time()
108+
end = monotonic()
109109
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
110110

111111

@@ -140,3 +140,33 @@ def ready(name: str, threshold: int) -> bool:
140140

141141
assert retry_until(ready, 1, args=("pane", 3), interval=0)
142142
assert seen == [("pane", 3)] * 3
143+
144+
145+
def test_wall_clock_step_does_not_end_the_wait(
146+
monkeypatch: pytest.MonkeyPatch,
147+
) -> None:
148+
"""A wall-clock jump mid-wait neither shortens nor lengthens the budget.
149+
150+
Regression: the budget was measured with :func:`time.time`, so an NTP
151+
correction, a container clock sync, or a VM resume landing inside the
152+
wait ended it early. Stepping :func:`time.time` forward by an hour here
153+
must not be observable, because nothing reads it.
154+
"""
155+
stepped = 0.0
156+
157+
def stepping_time() -> float:
158+
nonlocal stepped
159+
stepped += 3600.0
160+
return monotonic() + stepped
161+
162+
monkeypatch.setattr("time.time", stepping_time)
163+
164+
calls = 0
165+
166+
def true_on_third() -> bool:
167+
nonlocal calls
168+
calls += 1
169+
return calls >= 3
170+
171+
assert retry_until(true_on_third, 5)
172+
assert calls == 3

0 commit comments

Comments
 (0)