Files
synapse/tests/util/test_clock.py
Erik JohnstonandClaude Opus 5 3b70d7c70a Add a clock to the Rust crate
Rust code increasingly needs the current time somewhere it cannot cheaply
ask Python for it: on a Tokio worker without the GIL, or deep inside a data
structure where reaching back out to a `synapse.util.clock.Clock` would mean
threading a `Py<PyAny>` through everything. Up to now there was no clock at
all in `rust/src` — the rendezvous handlers each held a `Py<PyAny>` and
called `time_msec()` on it per request.

Add `synapse::clock`, a wall clock matching `Clock.time_msec()` so times mean
the same thing on both sides of the boundary. It reads `SystemTime::now()`,
which on Linux is a vDSO call rather than a syscall.

Synapse's tests run against a virtual reactor clock, and it is important that
time in tests only moves when a test says so. The test reactor therefore pins
this clock to its own time; `tests.unittest.TestCase` unpins it afterwards so
it cannot leak between tests.

Port both rendezvous handlers over, which removes a Python round-trip per
rendezvous request and exercises the virtualisation against the existing
tests that drive session eviction by advancing the reactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyuiV3m44ZbK9o24EnMMES
2026-07-27 10:53:27 +00:00

126 lines
4.3 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
#
import weakref
from synapse.synapse_rust import clock as rust_clock
from synapse.util.duration import Duration
from tests.unittest import HomeserverTestCase, TestCase
class ClockTestCase(HomeserverTestCase):
def test_looping_calls_are_gced(self) -> None:
"""Test that looping calls are garbage collected after being stopped.
The `Clock` tracks looping calls so to allow stopping of all looping
calls via the clock.
"""
clock = self.hs.get_clock()
# Create a new looping call, and take a weakref to it.
call = clock.looping_call(lambda: None, Duration(seconds=1))
weak_call = weakref.ref(call)
# Stop the looping call. It should get garbage collected after this.
call.stop()
# Delete our strong reference to the call (otherwise it won't get garbage collected).
del call
# Check that the call has been garbage collected.
self.assertIsNone(weak_call())
def test_looping_calls_stopped_on_clock_shutdown(self) -> None:
"""Test that looping calls are stopped when the clock is shut down."""
clock = self.hs.get_clock()
was_called = False
def on_call() -> None:
nonlocal was_called
was_called = True
# Create a new looping call.
call = clock.looping_call(on_call, Duration(seconds=1))
weak_call = weakref.ref(call)
del call # Remove our strong reference to the call.
# The call should still exist.
self.assertIsNotNone(weak_call())
# Advance the clock to trigger the call.
self.reactor.advance(2)
self.assertTrue(was_called)
# Shut down the clock, which should stop the looping call.
clock.shutdown()
# The call should have been garbage collected.
self.assertIsNone(weak_call())
# Advance the clock again; the call should not be called again.
was_called = False
self.reactor.advance(2)
self.assertFalse(was_called)
class RustClockTestCase(HomeserverTestCase):
"""Tests for `synapse.synapse_rust.clock`, the Rust side's view of the time.
Rust can't reach the (virtual) reactor clock the tests run against, so the
test reactor pushes the time over to it; see
`tests.server.ThreadedMemoryReactorClock.seconds`.
"""
def test_matches_the_synapse_clock(self) -> None:
self.assertEqual(rust_clock.time_msec(), self.hs.get_clock().time_msec())
def test_follows_the_reactor(self) -> None:
before = rust_clock.time_msec()
self.reactor.advance(37)
self.assertEqual(rust_clock.time_msec(), before + 37 * 1000)
self.assertEqual(rust_clock.time_msec(), self.hs.get_clock().time_msec())
def test_is_up_to_date_inside_a_looping_call(self) -> None:
"""Rust must see the new time from callbacks fired *during* an advance.
This is why we hook `seconds()` rather than `advance()`: expiring things
by age from a looping call is the main reason the Rust clock exists.
"""
seen: list[int] = []
self.hs.get_clock().looping_call(
lambda: seen.append(rust_clock.time_msec()), Duration(seconds=1)
)
expected = self.hs.get_clock().time_msec() + 1000
self.reactor.advance(1)
self.assertEqual(seen, [expected])
class RealRustClockTestCase(TestCase):
"""The virtual time must not leak out of the tests that pin it."""
def test_clock_is_real_without_a_virtual_reactor(self) -> None:
# This test never builds a test reactor, so the Rust clock should be
# reporting real time — as it will be for anything running in a real
# homeserver. If a previous test leaked its pinned time we'd see that
# instead, because the whole suite shares one process.
self.assertGreater(rust_clock.time_msec(), 1_600_000_000_000)