Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Synapse 1.152.1 (2026-05-07)

## Security Fixes

- Prevent CPU starvation (Denial of Service) under worker lock contention, additionally capping the `WorkerLock` time out interval to a maximum of 60 seconds. Contributed by Famedly. ([\#19394](https://github.com/element-hq/synapse/issues/19394), ELEMENTSEC-2026-1706, [GHSA-8q93-326v-3m7g](https://github.com/element-hq/synapse/security/advisories/GHSA-8q93-326v-3m7g), CVE pending)
- Prevent pagination ending when a page is full of rejected events. (ELEMENTSEC-2025-1636, [GHSA-6qf2-7x63-mm6v](https://github.com/element-hq/synapse/security/advisories/GHSA-6qf2-7x63-mm6v), CVE pending)


# Synapse 1.152.0 (2026-04-28)

No significant changes since 1.152.0rc1.
Expand Down
6 changes: 6 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
matrix-synapse-py3 (1.152.1) stable; urgency=medium

* New Synapse release 1.152.1.

-- Synapse Packaging team <packages@matrix.org> Thu, 07 May 2026 13:29:05 +0100

matrix-synapse-py3 (1.152.0) stable; urgency=medium

* New Synapse release 1.152.0.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
version = "1.152.0"
version = "1.152.1"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
Expand Down
11 changes: 6 additions & 5 deletions synapse/handlers/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ async def get_messages(
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
Expand Down Expand Up @@ -649,7 +649,7 @@ async def get_messages(
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
Expand All @@ -672,11 +672,12 @@ async def get_messages(

next_token = from_token.copy_and_replace(StreamKeyType.ROOM, next_key)

# if no events are returned from pagination, that implies
# we have reached the end of the available events.
# if no events are returned from pagination (this page is empty)
# and there aren't any more pages (not limited),
# that implies we have reached the end of the available events.
# In that case we do not return end, to tell the client
# there is no need for further queries.
if not events:
if not limited and not events:
return GetMessagesResult(
messages_chunk=[],
bundled_aggregations={},
Expand Down
117 changes: 85 additions & 32 deletions synapse/handlers/worker_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
# will not disappear under our feet as long as we don't delete the room.
NEW_EVENT_DURING_PURGE_LOCK_NAME = "new_event_during_purge_lock"

WORKER_LOCK_MAX_RETRY_INTERVAL = Duration(seconds=60)
WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION = Duration(minutes=10)


class WorkerLocksHandler:
"""A class for waiting on taking out locks, rather than using the storage
Expand Down Expand Up @@ -206,9 +209,10 @@ class WaitingLock:
lock_name: str
lock_key: str
write: bool | None
start_ts_ms: int = 0
deferred: "defer.Deferred[None]" = attr.Factory(defer.Deferred)
_inner_lock: Lock | None = None
_retry_interval: float = 0.1
_timeout_interval: float = 0.1
_lock_span: "opentracing.Scope" = attr.Factory(
lambda: start_active_span("WaitingLock.lock")
)
Expand All @@ -220,6 +224,7 @@ def release_lock(self) -> None:
self.deferred.callback(None)

async def __aenter__(self) -> None:
self.start_ts_ms = self.clock.time_msec()
self._lock_span.__enter__()

with start_active_span("WaitingLock.waiting_for_lock"):
Expand All @@ -240,19 +245,44 @@ async def __aenter__(self) -> None:
break

try:
# Wait until the we get notified the lock might have been
# Wait until the notification that the lock might have been
# released (by the deferred being resolved). We also
# periodically wake up in case the lock was released but we
# periodically wake up in case the lock was released, but we
# weren't notified.
with PreserveLoggingContext():
timeout = self._get_next_retry_interval()
await timeout_deferred(
deferred=self.deferred,
timeout=timeout,
timeout=self._timeout_interval,
clock=self.clock,
)
except Exception:
pass
except defer.TimeoutError:
# Only increment the timeout value if this was an actual timeout
# (defer.TimeoutError)
self._increment_timeout_interval()

now_ms = self.clock.time_msec()
time_spent_trying_to_lock = Duration(
milliseconds=now_ms - self.start_ts_ms
)
if (
time_spent_trying_to_lock.as_millis()
> WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION.as_millis()
):
logger.warning(
"(WaitingLock (%s, %s)) Time spent waiting to acquire lock "
"is getting excessive: %ss. There may be a deadlock.",
self.lock_name,
self.lock_key,
time_spent_trying_to_lock.as_secs(),
)

except Exception as e:
logger.warning(
"Caught an exception while waiting on WaitingLock(lock_name=%s, lock_key=%s): %r",
self.lock_name,
self.lock_key,
e,
)
Comment thread
jason-famedly marked this conversation as resolved.

return await self._inner_lock.__aenter__()

Expand All @@ -273,15 +303,14 @@ async def __aexit__(

return r

def _get_next_retry_interval(self) -> float:
next = self._retry_interval
self._retry_interval = max(5, next * 2)
if self._retry_interval > Duration(minutes=10).as_secs(): # >7 iterations
logger.warning(
"Lock timeout is getting excessive: %ss. There may be a deadlock.",
self._retry_interval,
)
return next * random.uniform(0.9, 1.1)
def _increment_timeout_interval(self) -> float:
next_interval = self._timeout_interval
next_interval = min(WORKER_LOCK_MAX_RETRY_INTERVAL.as_secs(), next_interval * 2)

# The jitter value is maintained for the timeout, to help avoid a "thundering
# herd" situation when all locks may time out at the same time.
self._timeout_interval = next_interval * random.uniform(0.9, 1.1)
Comment thread
jason-famedly marked this conversation as resolved.
return self._timeout_interval


@attr.s(auto_attribs=True, eq=False)
Expand All @@ -294,10 +323,11 @@ class WaitingMultiLock:
store: LockStore
handler: WorkerLocksHandler

start_ts_ms: int = 0
deferred: "defer.Deferred[None]" = attr.Factory(defer.Deferred)

_inner_lock_cm: AsyncContextManager | None = None
_retry_interval: float = 0.1
_timeout_interval: float = 0.1
_lock_span: "opentracing.Scope" = attr.Factory(
lambda: start_active_span("WaitingLock.lock")
)
Expand All @@ -309,6 +339,7 @@ def release_lock(self) -> None:
self.deferred.callback(None)

async def __aenter__(self) -> None:
self.start_ts_ms = self.clock.time_msec()
self._lock_span.__enter__()

with start_active_span("WaitingLock.waiting_for_lock"):
Expand All @@ -324,19 +355,42 @@ async def __aenter__(self) -> None:
break

try:
# Wait until the we get notified the lock might have been
# Wait until the notification that the lock might have been
# released (by the deferred being resolved). We also
# periodically wake up in case the lock was released but we
# periodically wake up in case the lock was released, but we
# weren't notified.
with PreserveLoggingContext():
timeout = self._get_next_retry_interval()
await timeout_deferred(
deferred=self.deferred,
timeout=timeout,
timeout=self._timeout_interval,
clock=self.clock,
)
except Exception:
pass
except defer.TimeoutError:
# Only increment the timeout value if this was an actual timeout
# (defer.TimeoutError)
self._increment_timeout_interval()

now_ms = self.clock.time_msec()
time_spent_trying_to_lock = Duration(
milliseconds=now_ms - self.start_ts_ms
)
if (
time_spent_trying_to_lock.as_millis()
> WORKER_LOCK_EXCESSIVE_WAITING_WARN_DURATION.as_millis()
):
logger.warning(
"(WaitingMultiLock (%r)) Time spent waiting to acquire lock "
"is getting excessive: %ss. There may be a deadlock.",
self.lock_names,
time_spent_trying_to_lock.as_secs(),
)

except Exception as e:
logger.warning(
"Caught an exception while waiting on WaitingMultiLock(lock_names=%r): %r",
self.lock_names,
e,
)
Comment thread
jason-famedly marked this conversation as resolved.

assert self._inner_lock_cm
await self._inner_lock_cm.__aenter__()
Expand All @@ -360,12 +414,11 @@ async def __aexit__(

return r

def _get_next_retry_interval(self) -> float:
next = self._retry_interval
self._retry_interval = max(5, next * 2)
if self._retry_interval > Duration(minutes=10).as_secs(): # >7 iterations
logger.warning(
"Lock timeout is getting excessive: %ss. There may be a deadlock.",
self._retry_interval,
)
return next * random.uniform(0.9, 1.1)
def _increment_timeout_interval(self) -> float:
next_interval = self._timeout_interval
next_interval = min(WORKER_LOCK_MAX_RETRY_INTERVAL.as_secs(), next_interval * 2)

# The jitter value is maintained for the timeout, to help avoid a "thundering
# herd" situation when all locks may time out at the same time.
self._timeout_interval = next_interval * random.uniform(0.9, 1.1)
Comment thread
jason-famedly marked this conversation as resolved.
return self._timeout_interval
13 changes: 10 additions & 3 deletions synapse/storage/databases/main/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -2425,12 +2425,19 @@ async def paginate_room_events_by_topological_ordering(
event_filter: If provided filters the events to those that match the filter.

Returns:
The results as a list of events, a token that points to the end of
the result set, and a boolean to indicate if there were more events
but we hit the limit. If no events are returned then the end of the
- The results as a list of events;
- a token that points to the end of the result set; and
- a boolean to indicate if there were more events
but we hit the limit (`limited`)

If no events are returned and `limited` is false, then the end of the
stream has been reached (i.e. there are no events between `from_key`
and `to_key`).

When `limited` is true, that means that more pagination can be attempted.
Note that `limited` can be true even if no events are returned,
because rejected events are filtered out after the limit check.

When Direction.FORWARDS: from_key < x <= to_key, (ascending order)
When Direction.BACKWARDS: from_key >= x > to_key, (descending order)
"""
Expand Down
Loading
Loading