Skip to content

Commit 0dfcffa

Browse files
authored
Fix looping calls not getting GCed. (#19416)
The `Clock` tracks looping calls to allow cancelling of all looping calls. However, this stopped them from getting garbage collected. This was introduced in #18828 Fixes #19392
1 parent d02796f commit 0dfcffa

File tree

3 files changed

+84
-2
lines changed

3 files changed

+84
-2
lines changed

changelog.d/19416.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix memory leak caused by not cleaning up stopped looping calls. Introduced in v1.140.0.

synapse/util/clock.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515

1616

1717
import logging
18+
from functools import wraps
1819
from typing import (
1920
Any,
2021
Callable,
2122
)
23+
from weakref import WeakSet
2224

2325
from typing_extensions import ParamSpec
2426
from zope.interface import implementer
@@ -86,7 +88,7 @@ def __init__(self, reactor: ISynapseThreadlessReactor, server_name: str) -> None
8688
self._delayed_call_id: int = 0
8789
"""Unique ID used to track delayed calls"""
8890

89-
self._looping_calls: list[LoopingCall] = []
91+
self._looping_calls: WeakSet[LoopingCall] = WeakSet()
9092
"""List of active looping calls"""
9193

9294
self._call_id_to_delayed_call: dict[int, IDelayedCall] = {}
@@ -193,6 +195,7 @@ def _looping_call_common(
193195
if now:
194196
looping_call_context_string = "looping_call_now"
195197

198+
@wraps(f)
196199
def wrapped_f(*args: P.args, **kwargs: P.kwargs) -> Deferred:
197200
clock_debug_logger.debug(
198201
"%s(%s): Executing callback", looping_call_context_string, instance_id
@@ -240,7 +243,7 @@ def wrapped_f(*args: P.args, **kwargs: P.kwargs) -> Deferred:
240243
with context.PreserveLoggingContext():
241244
d = call.start(duration.as_secs(), now=now)
242245
d.addErrback(log_failure, "Looping call died", consumeErrors=False)
243-
self._looping_calls.append(call)
246+
self._looping_calls.add(call)
244247

245248
clock_debug_logger.debug(
246249
"%s(%s): Scheduled looping call every %sms later",
@@ -302,6 +305,7 @@ def call_later(
302305
if self._is_shutdown:
303306
raise Exception("Cannot start delayed call. Clock has been shutdown")
304307

308+
@wraps(callback)
305309
def wrapped_callback(*args: Any, **kwargs: Any) -> None:
306310
clock_debug_logger.debug("call_later(%s): Executing callback", call_id)
307311

tests/util/test_clock.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 Element Creations Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
#
15+
16+
import weakref
17+
18+
from synapse.util.duration import Duration
19+
20+
from tests.unittest import HomeserverTestCase
21+
22+
23+
class ClockTestCase(HomeserverTestCase):
24+
def test_looping_calls_are_gced(self) -> None:
25+
"""Test that looping calls are garbage collected after being stopped.
26+
27+
The `Clock` tracks looping calls so to allow stopping of all looping
28+
calls via the clock.
29+
"""
30+
clock = self.hs.get_clock()
31+
32+
# Create a new looping call, and take a weakref to it.
33+
call = clock.looping_call(lambda: None, Duration(seconds=1))
34+
35+
weak_call = weakref.ref(call)
36+
37+
# Stop the looping call. It should get garbage collected after this.
38+
call.stop()
39+
40+
# Delete our strong reference to the call (otherwise it won't get garbage collected).
41+
del call
42+
43+
# Check that the call has been garbage collected.
44+
self.assertIsNone(weak_call())
45+
46+
def test_looping_calls_stopped_on_clock_shutdown(self) -> None:
47+
"""Test that looping calls are stopped when the clock is shut down."""
48+
clock = self.hs.get_clock()
49+
50+
was_called = False
51+
52+
def on_call() -> None:
53+
nonlocal was_called
54+
was_called = True
55+
56+
# Create a new looping call.
57+
call = clock.looping_call(on_call, Duration(seconds=1))
58+
weak_call = weakref.ref(call)
59+
del call # Remove our strong reference to the call.
60+
61+
# The call should still exist.
62+
self.assertIsNotNone(weak_call())
63+
64+
# Advance the clock to trigger the call.
65+
self.reactor.advance(2)
66+
self.assertTrue(was_called)
67+
68+
# Shut down the clock, which should stop the looping call.
69+
clock.shutdown()
70+
71+
# The call should have been garbage collected.
72+
self.assertIsNone(weak_call())
73+
74+
# Advance the clock again; the call should not be called again.
75+
was_called = False
76+
self.reactor.advance(2)
77+
self.assertFalse(was_called)

0 commit comments

Comments
 (0)