make faketime clocking work on the tests

⏺ 47/47 pass! Every single login test passes (excluding the 3 soft_logout tests which are a separate FutureCache issue). All ratelimit tests, SSO tests, JWT tests, CAS tests, SAML tests, appservice tests, username picker
  tests — all pass without Twisted installed.

  The key fixes:
  1. Linked reactor and clock — FakeReactor.advance() delegates to NativeClock.advance(), so self.reactor.advance(N) and self.clock.advance(N) advance the same fake time
  2. Clock starts in fake-time mode — hs_clock.advance(0) in get_clock() enables fake time from the start
  3. await_result advances by 0.1s per iteration — fires pending sleeps (like ratelimit pause) within the timeout window
  4. Ratelimit pause uses clock.sleep() — works with fake time, so tests can advance past it
This commit is contained in:
Matthew Hodgson
2026-03-22 23:31:48 +00:00
parent 17e2220793
commit abb68d849d
3 changed files with 53 additions and 35 deletions

View File

@@ -315,9 +315,7 @@ class _AsyncResource(metaclass=abc.ABCMeta):
callback_return = await self._async_render(request)
except LimitExceededError as e:
if e.pause:
# Use real asyncio.sleep for the anti-hammering pause,
# not fake-time clock.sleep, so tests don't hang.
await asyncio.sleep(e.pause)
await self._clock.sleep(Duration(seconds=e.pause))
raise
if callback_return is not None:

View File

@@ -354,13 +354,10 @@ class FakeChannel:
if _time.monotonic() > deadline:
raise TimedOutException("Timed out waiting for request to finish.")
# Advance fake time by 0.01s per pump iteration. This keeps
# fake time progressing (matching old Twisted behavior where
# reactor.advance(0.1) was called each iteration) and fires
# any pending sleeps.
if self._clock is not None:
self._clock.advance(0.01)
# Advance fake time by a small amount per iteration.
# This fires pending sleeps (e.g., ratelimit pauses) while
# keeping time advancement predictable. The old Twisted
# MemoryReactorClock.advance(0.1) did the same.
self._reactor.advance(0.1)
# Drive asyncio event loop for DB operations, task completions, etc.
if not loop.is_closed():
@@ -562,24 +559,26 @@ class ThreadedMemoryReactorClock:
``advance()``, ``callFromThread()``, ``getThreadPool()``, ``run()``.
This replaces the Twisted ``MemoryReactorClock`` with a pure-asyncio
implementation. Time is NOT managed here — use ``NativeClock`` for
fake-time control. This class exists only because some code expects
a "reactor" object.
implementation. Time advancement is delegated to a ``NativeClock``
instance — call ``set_clock()`` after construction (done automatically
by ``get_clock()``).
"""
def __init__(self) -> None:
from concurrent.futures import ThreadPoolExecutor
self._clock: Any = None # will be set to a NativeClock by get_clock()
self._thread_callbacks: deque[Callable[..., Any]] = deque()
self.lookups: dict[str, str] = {}
self._executor = ThreadPoolExecutor(max_workers=4)
self.triggers: dict[Any, Any] = {}
# Provide a threadpool attribute that tests/unittest.py expects
self.threadpool = _FakeThreadPool()
def set_clock(self, clock: Any) -> None:
"""Link this reactor to a NativeClock so advance() delegates to it."""
self._clock = clock
def seconds(self) -> float:
"""Return the current (fake) time. Used by IReactorTime compatibility."""
"""Return the current (fake) time."""
if self._clock is not None:
return self._clock.time()
return time.time()
def run(self) -> None:
@@ -587,11 +586,15 @@ class ThreadedMemoryReactorClock:
pass
def advance(self, amount: float) -> None:
"""Process any pending callFromThread callbacks.
"""Advance fake time by *amount* seconds.
Note: fake-time advancement is handled by ``NativeClock.advance()``,
not here. This method only drains the callFromThread queue.
Delegates to ``NativeClock.advance()`` to fire any pending
``clock.sleep()`` calls, then drains the ``callFromThread`` queue.
"""
if self._clock is not None:
self._clock.advance(amount)
# Drain callFromThread callbacks
while True:
try:
callback = self._thread_callbacks.popleft()
@@ -627,9 +630,13 @@ class ThreadedMemoryReactorClock:
def suggestThreadPoolSize(self, size: int) -> None:
pass
# Provide pump() for compatibility with code that calls reactor.pump()
def pump(self, timings: list[float] | None = None) -> None:
self.advance(0)
"""Pump the reactor — advance time for each timing value."""
if timings:
for t in timings:
self.advance(t)
else:
self.advance(0)
class _FakeThreadPool:
@@ -795,11 +802,26 @@ class ThreadPool:
def get_clock() -> tuple[ThreadedMemoryReactorClock, Clock]:
# Ignore the linter error since this is an expected usage of creating a `Clock` for
# testing purposes.
reactor = ThreadedMemoryReactorClock()
"""Create a linked fake-reactor and clock for tests.
The reactor and clock share the same fake time source:
calling ``reactor.advance(N)`` or ``clock.advance(N)`` both advance
the same underlying fake time. The clock starts in fake-time mode
so that ``clock.time()`` returns deterministic values from the start.
"""
from synapse.util.clock import NativeClock
reactor = ThreadedMemoryReactorClock()
hs_clock = NativeClock(reactor, server_name="test_server") # type: ignore[multiple-internal-clocks]
# Link the reactor to the clock so reactor.advance() delegates properly.
reactor.set_clock(hs_clock)
# Put the clock into fake-time mode from the start.
# Tests that need deterministic time get it automatically;
# advance(0) enables fake time without changing the initial value.
hs_clock.advance(0)
return reactor, hs_clock

View File

@@ -900,22 +900,20 @@ class HomeserverTestCase(TestCase):
return hs
def pump(self, by: float = 0.0) -> None:
"""
Pump both the test reactor and the asyncio event loop,
advancing fake time on the NativeClock.
"""Advance fake time and drive the asyncio event loop.
``reactor.advance()`` delegates to ``clock.advance()``, so calling
either one advances the same fake-time source.
"""
import asyncio
loop = asyncio.get_event_loop()
# Advance fake time on the clock (fires pending sleeps)
self.clock.advance(by)
# Advance Twisted's fake clock too (for any Twisted-driven code)
# Advance fake time (fires pending sleeps) AND drain callFromThread
self.reactor.advance(by)
# Process asyncio callbacks (executor results, task completions, etc.)
if not loop.is_closed() and not loop.is_running():
if not loop.is_closed():
loop.run_until_complete(asyncio.sleep(0))
def get_success(self, d: Awaitable[TV], by: float = 0.0) -> TV: