From e59e5490941c078c1b74fe193f8d7d670ac6f58f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 2 Feb 2026 23:22:15 +0000
Subject: [PATCH 001/121] Bump actions/setup-go from 6.1.0 to 6.2.0 in the
minor-and-patches group (#19423)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the minor-and-patches group with 1 update:
[actions/setup-go](https://github.com/actions/setup-go).
Updates `actions/setup-go` from 6.1.0 to 6.2.0
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/latest_deps.yml | 2 +-
.github/workflows/tests.yml | 2 +-
.github/workflows/twisted_trunk.yml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/latest_deps.yml b/.github/workflows/latest_deps.yml
index a85551854c0..9e0f2c384e9 100644
--- a/.github/workflows/latest_deps.yml
+++ b/.github/workflows/latest_deps.yml
@@ -209,7 +209,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+ - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index fc544fcfde2..bb2e167e234 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -702,7 +702,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+ - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
diff --git a/.github/workflows/twisted_trunk.yml b/.github/workflows/twisted_trunk.yml
index 14b48317dbd..bd5c79f16dc 100644
--- a/.github/workflows/twisted_trunk.yml
+++ b/.github/workflows/twisted_trunk.yml
@@ -182,7 +182,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+ - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
From 065ff194c23ca9c326460ace5b55edc0e5eff3d7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 3 Feb 2026 00:19:25 +0000
Subject: [PATCH 002/121] Bump serde_json from 1.0.145 to 1.0.148 in the
patches group across 1 directory (#19391)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the patches group with 1 update in the / directory:
[serde_json](https://github.com/serde-rs/json).
Updates `serde_json` from 1.0.145 to 1.0.148
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Devon Hudson
---
.github/workflows/tests.yml | 2 +-
Cargo.lock | 12 +++++++++---
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index bb2e167e234..715dfa93d9a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -238,7 +238,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
with:
- toolchain: nightly-2025-04-23
+ toolchain: nightly-2026-02-01
components: clippy
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
diff --git a/Cargo.lock b/Cargo.lock
index 0edfef68697..8d1cd967d5c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1207,15 +1207,15 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.145"
+version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
- "ryu",
"serde",
"serde_core",
+ "zmij",
]
[[package]]
@@ -1921,3 +1921,9 @@ dependencies = [
"quote",
"syn",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
From 84d591934ba27054fb42e807928df0901ac1eea7 Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Tue, 3 Feb 2026 15:33:39 +0000
Subject: [PATCH 003/121] Add notes that new experimental features should have
associated tracking issues. (#19410)
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19410.misc | 1 +
docs/development/experimental_features.md | 27 +++++++++++++++++++++++
synapse/config/experimental.py | 6 ++++-
3 files changed, 33 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/19410.misc
diff --git a/changelog.d/19410.misc b/changelog.d/19410.misc
new file mode 100644
index 00000000000..97a4070304a
--- /dev/null
+++ b/changelog.d/19410.misc
@@ -0,0 +1 @@
+Add notes that new experimental features should have associated tracking issues.
\ No newline at end of file
diff --git a/docs/development/experimental_features.md b/docs/development/experimental_features.md
index d6b11496cc4..5a86017ecfd 100644
--- a/docs/development/experimental_features.md
+++ b/docs/development/experimental_features.md
@@ -35,3 +35,30 @@ but one should be used if unsure.
New experimental configuration flags should be added under the `experimental`
configuration key (see the `synapse.config.experimental` file) and either explain
(briefly) what is being enabled, or include the MSC number.
+The configuration flag should link to the tracking issue for the experimental feature (see below).
+
+
+## Tracking issues for experimental features
+
+In the interest of having some documentation around experimental features, without
+polluting the stable documentation, all new experimental features should have a tracking issue with
+[the `T-ExperimentalFeature` label](https://github.com/element-hq/synapse/issues?q=sort%3Aupdated-desc+state%3Aopen+label%3A%22T-ExperimentalFeature%22),
+kept open as long as the experimental feature is present in Synapse.
+
+The configuration option for the feature should have a comment linking to the tracking issue,
+for ease of discoverability.
+
+As a guideline, the issue should contain:
+
+- Context for why this experimental feature is in Synapse
+ - This could well be a link to somewhere else, where this context is already available.
+- If applicable, why the feature is enabled by default. (Why do we need to enable it by default and why is it safe?)
+- If applicable, setup instructions for any non-standard components or configuration needed by the feature.
+ (Ideally this will be moved to the configuration manual after stabilisation.)
+- Design decisions behind the Synapse implementation.
+ (Ideally this will be moved to the developers' documentation after stabilisation.)
+- Any caveats around the current implementation of the feature, such as:
+ - missing aspects
+ - breakage or incompatibility that is expected if/when the feature is stabilised,
+ or when the feature is turned on/off
+- Criteria for how we know whether we can remove the feature in the future.
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 0150b71621c..1bd70ead09a 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -366,7 +366,11 @@ class MSC3866Config:
class ExperimentalConfig(Config):
- """Config section for enabling experimental features"""
+ """Config section for enabling experimental features
+
+ All new experimental features should have a tracking issue with the
+ `T-ExperimentalFeatures` label, kept open as long as the experimental
+ feature is present in Synapse."""
section = "experimental"
From 98a540a41d6fd5920fad2bab900425e869fb9cbd Mon Sep 17 00:00:00 2001
From: Renaud Allard
Date: Tue, 3 Feb 2026 16:40:20 +0100
Subject: [PATCH 004/121] Fix a typo in check_dependencies.py which makes
setuptools_rust a running dependency (#19417)
### Pull Request Checklist
* [x] Pull request is based on the develop branch
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
There is a typo in check_dependencies.py which makes setuptools_rust a
runtime requirement, but there is no need for it at runtime. This patch
solves the typo. I tested starting 1.146.0 with this patch and without
setuptools_rust and it starts correctly
---
changelog.d/19417.bugfix | 1 +
synapse/util/check_dependencies.py | 3 ++-
tests/util/test_check_dependencies.py | 6 +++---
3 files changed, 6 insertions(+), 4 deletions(-)
create mode 100644 changelog.d/19417.bugfix
diff --git a/changelog.d/19417.bugfix b/changelog.d/19417.bugfix
new file mode 100644
index 00000000000..9f5c9c02d94
--- /dev/null
+++ b/changelog.d/19417.bugfix
@@ -0,0 +1 @@
+Fix a typo that incorrectly made `setuptools_rust` a runtime dependency.
diff --git a/synapse/util/check_dependencies.py b/synapse/util/check_dependencies.py
index 7e92b555928..cf7573c99d3 100644
--- a/synapse/util/check_dependencies.py
+++ b/synapse/util/check_dependencies.py
@@ -32,6 +32,7 @@
from packaging.markers import Marker, Value, Variable, default_environment
from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
DISTRIBUTION_NAME = "matrix-synapse"
@@ -96,7 +97,7 @@ def _should_ignore_runtime_requirement(req: Requirement) -> bool:
# In any case, workaround this by ignoring setuptools_rust here. (It might be
# slightly cleaner to put `setuptools_rust` in a `build` extra or similar, but for
# now let's do something quick and dirty.
- if req.name == "setuptools_rust":
+ if canonicalize_name(req.name) == "setuptools-rust":
return True
return False
diff --git a/tests/util/test_check_dependencies.py b/tests/util/test_check_dependencies.py
index b7a23dcd9da..eed0519c447 100644
--- a/tests/util/test_check_dependencies.py
+++ b/tests/util/test_check_dependencies.py
@@ -201,13 +201,13 @@ def test_setuptools_rust_ignored(self) -> None:
"""
with patch(
"synapse.util.check_dependencies.metadata.requires",
- return_value=["setuptools_rust >= 1.3"],
+ return_value=["setuptools-rust >= 1.3"],
):
with self.mock_installed_package(None):
- # should not raise, even if setuptools_rust is not installed
+ # should not raise, even if setuptools-rust is not installed
check_requirements()
with self.mock_installed_package(old):
- # We also ignore old versions of setuptools_rust
+ # We also ignore old versions of setuptools-rust
check_requirements()
def test_python_version_markers_respected(self) -> None:
From 3048ff8b262995254e3ff2c07de3474d5bfa6daf Mon Sep 17 00:00:00 2001
From: Devon Hudson
Date: Tue, 3 Feb 2026 08:56:37 -0700
Subject: [PATCH 005/121] 1.147.0rc1
---
CHANGES.md | 20 ++++++++++++++++++++
changelog.d/19306.misc | 1 -
changelog.d/19399.misc | 1 -
changelog.d/19400.misc | 1 -
changelog.d/19402.misc | 1 -
changelog.d/19405.misc | 1 -
changelog.d/19410.misc | 1 -
changelog.d/19412.misc | 1 -
changelog.d/19416.bugfix | 1 -
changelog.d/19417.bugfix | 1 -
debian/changelog | 6 ++++++
pyproject.toml | 2 +-
schema/synapse-config.schema.yaml | 2 +-
13 files changed, 28 insertions(+), 11 deletions(-)
delete mode 100644 changelog.d/19306.misc
delete mode 100644 changelog.d/19399.misc
delete mode 100644 changelog.d/19400.misc
delete mode 100644 changelog.d/19402.misc
delete mode 100644 changelog.d/19405.misc
delete mode 100644 changelog.d/19410.misc
delete mode 100644 changelog.d/19412.misc
delete mode 100644 changelog.d/19416.bugfix
delete mode 100644 changelog.d/19417.bugfix
diff --git a/CHANGES.md b/CHANGES.md
index 516ac4dbfaa..9c688402ef0 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,23 @@
+# Synapse 1.147.0rc1 (2026-02-03)
+
+## Bugfixes
+
+- Fix memory leak caused by not cleaning up stopped looping calls. Introduced in v1.140.0. ([\#19416](https://github.com/element-hq/synapse/issues/19416))
+- Fix a typo that incorrectly made `setuptools_rust` a runtime dependency. ([\#19417](https://github.com/element-hq/synapse/issues/19417))
+
+## Internal Changes
+
+- Prune stale entries from `sliding_sync_connection_required_state` table. ([\#19306](https://github.com/element-hq/synapse/issues/19306))
+- Update "Event Send Time Quantiles" graph to only use dots for the event persistence rate (Grafana dashboard). ([\#19399](https://github.com/element-hq/synapse/issues/19399))
+- Update and align Grafana dashboard to use regex matching for `job` selectors (`job=~"$job"`) so the "all" value works correctly across all panels. ([\#19400](https://github.com/element-hq/synapse/issues/19400))
+- Don't retry joining partial state rooms all at once on startup. ([\#19402](https://github.com/element-hq/synapse/issues/19402))
+- Disallow requests to the health endpoint from containing trailing path characters. ([\#19405](https://github.com/element-hq/synapse/issues/19405))
+- Add notes that new experimental features should have associated tracking issues. ([\#19410](https://github.com/element-hq/synapse/issues/19410))
+- Bump `pyo3` from 0.26.0 to 0.27.2 and `pythonize` from 0.26.0 to 0.27.0. Contributed by @razvp @ ERCOM. ([\#19412](https://github.com/element-hq/synapse/issues/19412))
+
+
+
+
# Synapse 1.146.0 (2026-01-27)
No significant changes since 1.146.0rc1.
diff --git a/changelog.d/19306.misc b/changelog.d/19306.misc
deleted file mode 100644
index 463f87eac3a..00000000000
--- a/changelog.d/19306.misc
+++ /dev/null
@@ -1 +0,0 @@
-Prune stale entries from `sliding_sync_connection_required_state` table.
diff --git a/changelog.d/19399.misc b/changelog.d/19399.misc
deleted file mode 100644
index 0d02904f407..00000000000
--- a/changelog.d/19399.misc
+++ /dev/null
@@ -1 +0,0 @@
-Update "Event Send Time Quantiles" graph to only use dots for the event persistence rate (Grafana dashboard).
diff --git a/changelog.d/19400.misc b/changelog.d/19400.misc
deleted file mode 100644
index 33b0cb509c5..00000000000
--- a/changelog.d/19400.misc
+++ /dev/null
@@ -1 +0,0 @@
-Update and align Grafana dashboard to use regex matching for `job` selectors (`job=~"$job"`) so the "all" value works correctly across all panels.
diff --git a/changelog.d/19402.misc b/changelog.d/19402.misc
deleted file mode 100644
index 0e1ee104a78..00000000000
--- a/changelog.d/19402.misc
+++ /dev/null
@@ -1 +0,0 @@
-Don't retry joining partial state rooms all at once on startup.
diff --git a/changelog.d/19405.misc b/changelog.d/19405.misc
deleted file mode 100644
index f3be5b20274..00000000000
--- a/changelog.d/19405.misc
+++ /dev/null
@@ -1 +0,0 @@
-Disallow requests to the health endpoint from containing trailing path characters.
\ No newline at end of file
diff --git a/changelog.d/19410.misc b/changelog.d/19410.misc
deleted file mode 100644
index 97a4070304a..00000000000
--- a/changelog.d/19410.misc
+++ /dev/null
@@ -1 +0,0 @@
-Add notes that new experimental features should have associated tracking issues.
\ No newline at end of file
diff --git a/changelog.d/19412.misc b/changelog.d/19412.misc
deleted file mode 100644
index 6b811be799a..00000000000
--- a/changelog.d/19412.misc
+++ /dev/null
@@ -1 +0,0 @@
-Bump `pyo3` from 0.26.0 to 0.27.2 and `pythonize` from 0.26.0 to 0.27.0. Contributed by @razvp @ ERCOM.
\ No newline at end of file
diff --git a/changelog.d/19416.bugfix b/changelog.d/19416.bugfix
deleted file mode 100644
index f0c2872410a..00000000000
--- a/changelog.d/19416.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix memory leak caused by not cleaning up stopped looping calls. Introduced in v1.140.0.
diff --git a/changelog.d/19417.bugfix b/changelog.d/19417.bugfix
deleted file mode 100644
index 9f5c9c02d94..00000000000
--- a/changelog.d/19417.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix a typo that incorrectly made `setuptools_rust` a runtime dependency.
diff --git a/debian/changelog b/debian/changelog
index ac013ba1b8e..8168bde3a21 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+matrix-synapse-py3 (1.147.0~rc1) stable; urgency=medium
+
+ * New Synapse release 1.147.0rc1.
+
+ -- Synapse Packaging team Tue, 03 Feb 2026 08:53:17 -0700
+
matrix-synapse-py3 (1.146.0) stable; urgency=medium
* New Synapse release 1.146.0.
diff --git a/pyproject.toml b/pyproject.toml
index d61f7177bdc..a26247ab1eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
-version = "1.146.0"
+version = "1.147.0rc1"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml
index 3ed71967528..99c9b6e9bd1 100644
--- a/schema/synapse-config.schema.yaml
+++ b/schema/synapse-config.schema.yaml
@@ -1,5 +1,5 @@
$schema: https://element-hq.github.io/synapse/latest/schema/v1/meta.schema.json
-$id: https://element-hq.github.io/synapse/schema/synapse/v1.146/synapse-config.schema.json
+$id: https://element-hq.github.io/synapse/schema/synapse/v1.147/synapse-config.schema.json
type: object
properties:
modules:
From 292e1aabdddddb38c054d71c90fe6021a6562eec Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 3 Feb 2026 22:27:18 +0000
Subject: [PATCH 006/121] Bump bytes from 1.11.0 to 1.11.1 (#19432)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
Release notes
* An attacker could create a malicious public key that reveals portions
of your
private key when using certain uncommon elliptic curves (binary curves).
This version now includes additional security checks to prevent this
attack.
This issue only affects binary elliptic curves, which are rarely used in
real-world applications. Credit to **XlabAI Team of Tencent Xuanwu Lab
and
Atuin Automated Vulnerability Discovery Engine** for reporting the
issue.
**CVE-2026-26007**
* Support for ``SECT*`` binary elliptic curves is deprecated and will be
removed in the next release.
.. v46-0-4:
46.0.4 - 2026-01-27
Dropped support for win_arm64 wheels_.
Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.5.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index e1344f89d61..c981d139d4a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1416,9 +1416,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.48.0"
+version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
From 5be475f5a24b01df8e652838967b2809b78c6a43 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Feb 2026 13:57:14 +0100
Subject: [PATCH 018/121] Allow configuring the Rust HTTP client to use HTTP/2
only (#19457)
This allows the Rust HTTP client to be configured to force HTTP/2 even
on plaintext connections. This is useful in contexts where the remote
server is known to server HTTP/2 over plain text.
Added because we use the Synapse Rust HTTP client with the Synapse Pro
`event-cache` module. We use this because it's independent from the
Python reactor which makes things slower than expected.
Currently, the Synapse Rust HTTP client uses HTTP/1 which means a new
connection for every request. With HTTP/2, we can share the connection
across requests.
We want to see if this will make a performance difference and less
stress on the database connection situation, see
https://github.com/element-hq/synapse-rust-apps/issues/452#issuecomment-3897717599
Here is the sibling PR for using HTTP/2 on the Synapse Pro `event-cache`
module side: https://github.com/element-hq/synapse-pro-modules/pull/35
---
changelog.d/19457.misc | 1 +
rust/src/http_client.rs | 22 +++++++++++++++++-----
synapse/synapse_rust/http_client.pyi | 20 +++++++++++++++++++-
3 files changed, 37 insertions(+), 6 deletions(-)
create mode 100644 changelog.d/19457.misc
diff --git a/changelog.d/19457.misc b/changelog.d/19457.misc
new file mode 100644
index 00000000000..ccbb7f5b144
--- /dev/null
+++ b/changelog.d/19457.misc
@@ -0,0 +1 @@
+Allow configuring the Rust HTTP client to use HTTP/2 only.
diff --git a/rust/src/http_client.rs b/rust/src/http_client.rs
index b1e4f753b82..9bbdff8b454 100644
--- a/rust/src/http_client.rs
+++ b/rust/src/http_client.rs
@@ -171,15 +171,27 @@ struct HttpClient {
#[pymethods]
impl HttpClient {
#[new]
- pub fn py_new(reactor: Bound, user_agent: &str) -> PyResult {
+ #[pyo3(signature = (reactor, user_agent, http2_only = false))]
+ pub fn py_new(
+ reactor: Bound,
+ user_agent: &str,
+ http2_only: bool,
+ ) -> PyResult {
// Make sure the runtime gets installed
let _ = runtime(&reactor)?;
+ let mut builder = reqwest::Client::builder().user_agent(user_agent);
+
+ if http2_only {
+ // Create the client with 'HTTP/2 prior knowledge' enabled, which
+ // means it will always use HTTP/2 for unencrypted connections
+ builder = builder.http2_prior_knowledge();
+ }
+
+ let client = builder.build().context("building reqwest client")?;
+
Ok(HttpClient {
- client: reqwest::Client::builder()
- .user_agent(user_agent)
- .build()
- .context("building reqwest client")?,
+ client,
reactor: reactor.unbind(),
})
}
diff --git a/synapse/synapse_rust/http_client.pyi b/synapse/synapse_rust/http_client.pyi
index 530d2be8e38..1814daec73e 100644
--- a/synapse/synapse_rust/http_client.pyi
+++ b/synapse/synapse_rust/http_client.pyi
@@ -21,7 +21,25 @@ class HttpClient:
The returned deferreds follow Synapse logcontext rules.
"""
- def __init__(self, reactor: ISynapseReactor, user_agent: str) -> None: ...
+ def __init__(
+ self,
+ reactor: ISynapseReactor,
+ user_agent: str,
+ http2_only: bool = False,
+ ) -> None:
+ """
+ Create a new HTTP client backed by reqwest.
+
+ Args:
+ reactor: The Twisted reactor to coordinate with
+ user_agent: The user agent to use for requests
+ http2_only: Whether to use HTTP/2 only, even on unencrypted connections. By
+ default, it will always use HTTP/1.1 over unencrypted connections, and
+ rely on TLS ALPN to negotiate HTTP/2.
+
+ Ensure the upstream server supports HTTP/2 before enabling this.
+ """
+
def get(self, url: str, response_limit: int) -> Deferred[bytes]: ...
def post(
self,
From 1592afba8d6d8421febe93162565edede48c9992 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:58:10 +0100
Subject: [PATCH 019/121] Bump github.com/docker/docker from
28.2.2+incompatible to 28.3.3+incompatible in /complement (#19436)
Bumps [github.com/docker/docker](https://github.com/docker/docker) from
28.2.2+incompatible to 28.3.3+incompatible.
Release notes
This release fixes an issue where, after a firewalld reload,
published container ports could be accessed directly from the local
network, even when they were intended to be accessible only via a
loopback address. CVE-2025-54388
/ GHSA-x4rx-4gw3-53p4
/ moby/moby#50506.
Update @azure/storage-blob to ^12.29.1 via
@actions/cache@5.0.1#1685
5.0.0
[!IMPORTANT]
actions/cache@v5 runs on the Node.js 24 runtime and
requires a minimum Actions Runner version of 2.327.1.
If you are using self-hosted runners, ensure they are updated before
upgrading.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Quentin Gliech
---
Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index c981d139d4a..47f81eaef1d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1024,9 +1024,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
-version = "0.12.26"
+version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
From e627b08786cb24c966e8f4f4da92b34035dde9fe Mon Sep 17 00:00:00 2001
From: Erik Johnston
Date: Tue, 17 Feb 2026 14:48:59 +0100
Subject: [PATCH 024/121] Add cargo.lock to Rust build hash (#19470)
This is so that when we update dependencies etc we correctly ensure that
the Rust library has been rebuilt.
---
changelog.d/19470.misc | 1 +
rust/build.rs | 14 ++++++++++++++
synapse/util/rust.py | 19 +++++++++++++------
3 files changed, 28 insertions(+), 6 deletions(-)
create mode 100644 changelog.d/19470.misc
diff --git a/changelog.d/19470.misc b/changelog.d/19470.misc
new file mode 100644
index 00000000000..e253d89b17e
--- /dev/null
+++ b/changelog.d/19470.misc
@@ -0,0 +1 @@
+Correctly refuse to start if the Rust workspace config has changed and the Rust library has not been rebuilt.
diff --git a/rust/build.rs b/rust/build.rs
index ef370e6b418..8755f3bfa38 100644
--- a/rust/build.rs
+++ b/rust/build.rs
@@ -29,6 +29,13 @@ fn main() -> Result<(), std::io::Error> {
}
}
+ // Manually add Cargo.toml's, Cargo.lock and build.rs to the hash, since changes to
+ // these files should also invalidate the built module.
+ paths.push("Cargo.toml".to_string());
+ paths.push("../Cargo.lock".to_string());
+ paths.push("../Cargo.toml".to_string());
+ paths.push("build.rs".to_string());
+
paths.sort();
let mut hasher = Blake2b512::new();
@@ -41,5 +48,12 @@ fn main() -> Result<(), std::io::Error> {
let hex_digest = hex::encode(hasher.finalize());
println!("cargo:rustc-env=SYNAPSE_RUST_DIGEST={hex_digest}");
+ // The default rules don't pick up trivial changes to the workspace config
+ // files, but we need to rebuild if those change to pick up the changed
+ // hashes.
+ println!("cargo::rerun-if-changed=.");
+ println!("cargo::rerun-if-changed=../Cargo.lock");
+ println!("cargo::rerun-if-changed=../Cargo.toml");
+
Ok(())
}
diff --git a/synapse/util/rust.py b/synapse/util/rust.py
index d1e1a259e42..a7c9e976ea4 100644
--- a/synapse/util/rust.py
+++ b/synapse/util/rust.py
@@ -40,24 +40,24 @@ def check_rust_lib_up_to_date() -> None:
return None
# Get the hash of all Rust source files
- rust_path = os.path.join(synapse_root, "rust", "src")
+ rust_path = os.path.join(synapse_root, "rust")
if not os.path.exists(rust_path):
return None
- hash = _hash_rust_files_in_directory(rust_path)
+ hash = _hash_rust_files_in_directory(synapse_root)
if hash != get_rust_file_digest():
raise Exception("Rust module outdated. Please rebuild using `poetry install`")
-def _hash_rust_files_in_directory(directory: str) -> str:
+def _hash_rust_files_in_directory(synapse_root: str) -> str:
"""Get the hash of all files in a directory (recursively)"""
- directory = os.path.abspath(directory)
+ src_directory = os.path.abspath(os.path.join(synapse_root, "rust", "src"))
paths = []
- dirs = [directory]
+ dirs = [src_directory]
while dirs:
dir = dirs.pop()
with os.scandir(dir) as d:
@@ -67,13 +67,20 @@ def _hash_rust_files_in_directory(directory: str) -> str:
else:
paths.append(entry.path)
+ # Manually add Cargo.toml's, Cargo.lock and build.rs to the hash, since
+ # changes to these files should also invalidate the built module.
+ paths.append(os.path.join(synapse_root, "rust", "Cargo.toml"))
+ paths.append(os.path.join(synapse_root, "rust", "build.rs"))
+ paths.append(os.path.join(synapse_root, "Cargo.lock"))
+ paths.append(os.path.join(synapse_root, "Cargo.toml"))
+
# We sort to make sure that we get a consistent and well-defined ordering.
paths.sort()
hasher = blake2b()
for path in paths:
- with open(os.path.join(directory, path), "rb") as f:
+ with open(path, "rb") as f:
hasher.update(f.read())
return hasher.hexdigest()
From bd0756c6ca6edd83149bfaf30a874d09bd9cbf0b Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Feb 2026 17:41:14 +0100
Subject: [PATCH 025/121] Revert "Fix `/sync` missing membership in
`state_after`" (#19474)
Reverts element-hq/synapse#19463
The complement tests haven't been reviewed and require more testing.
Discussed in the internal [backend team
lobby](https://matrix.to/#/!SGNQGPGUwtcPBUotTL:matrix.org/$XDARK2u2iLL5wWaxiL6tJYkLg80Sn6yWWEQib8ahl5Q?via=jki.re&via=element.io&via=matrix.org)
room.
---
changelog.d/19463.bugfix | 1 -
synapse/handlers/sync.py | 15 +++------------
2 files changed, 3 insertions(+), 13 deletions(-)
delete mode 100644 changelog.d/19463.bugfix
diff --git a/changelog.d/19463.bugfix b/changelog.d/19463.bugfix
deleted file mode 100644
index fe44c2ddf66..00000000000
--- a/changelog.d/19463.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix `/sync` missing membership event in `state_after` (experimental [MSC4222](https://github.com/matrix-org/matrix-spec-proposals/pull/4222) implementation) in some scenarios.
diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py
index ab050adb262..72e91d66ac4 100644
--- a/synapse/handlers/sync.py
+++ b/synapse/handlers/sync.py
@@ -1041,18 +1041,9 @@ async def compute_state_delta(
if event.sender not in first_event_by_sender_map:
first_event_by_sender_map[event.sender] = event
- # When using `state_after`, there is no special treatment with
- # regards to state also being in the `timeline`. Always fetch
- # relevant membership regardless of whether the state event is in
- # the `timeline`.
- if sync_config.use_state_after:
- members_to_fetch.add(event.sender)
- # For `state`, the client is supposed to do a flawed re-construction
- # of state over time by starting with the given `state` and layering
- # on state from the `timeline` as you go (flawed because state
- # resolution). In this case, we only need their membership in
- # `state` when their membership isn't already in the `timeline`.
- elif (EventTypes.Member, event.sender) not in timeline_state:
+ # We need the event's sender, unless their membership was in a
+ # previous timeline event.
+ if (EventTypes.Member, event.sender) not in timeline_state:
members_to_fetch.add(event.sender)
# FIXME: we also care about invite targets etc.
From 40171d036acec6f0315bbac77bd7f073bd64feb9 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Feb 2026 17:44:37 +0100
Subject: [PATCH 026/121] 1.148.0rc1
---
CHANGES.md | 24 ++++++++++++++++++++++++
changelog.d/19365.feature | 1 -
changelog.d/19406.misc | 1 -
changelog.d/19420.misc | 1 -
changelog.d/19429.removal | 1 -
changelog.d/19435.doc | 1 -
changelog.d/19457.misc | 1 -
changelog.d/19470.misc | 1 -
debian/changelog | 6 ++++++
pyproject.toml | 2 +-
schema/synapse-config.schema.yaml | 2 +-
11 files changed, 32 insertions(+), 9 deletions(-)
delete mode 100644 changelog.d/19365.feature
delete mode 100644 changelog.d/19406.misc
delete mode 100644 changelog.d/19420.misc
delete mode 100644 changelog.d/19429.removal
delete mode 100644 changelog.d/19435.doc
delete mode 100644 changelog.d/19457.misc
delete mode 100644 changelog.d/19470.misc
diff --git a/CHANGES.md b/CHANGES.md
index fe27ccb040a..e4bec3aa41c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,27 @@
+# Synapse 1.148.0rc1 (2026-02-17)
+
+## Features
+
+- Support sending and receiving [MSC4354 Sticky Event](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) metadata. ([\#19365](https://github.com/element-hq/synapse/issues/19365))
+
+## Improved Documentation
+
+- Fix reference to the `experimental_features` section of config. ([\#19435](https://github.com/element-hq/synapse/issues/19435))
+
+## Deprecations and Removals
+
+- Remove support for [MSC3244: Room version capabilities](https://github.com/matrix-org/matrix-spec-proposals/pull/3244) as the MSC was rejected. ([\#19429](https://github.com/element-hq/synapse/issues/19429))
+
+## Internal Changes
+
+- Add in-repo Complement tests so we can test Synapse specific behavior at an end-to-end level. ([\#19406](https://github.com/element-hq/synapse/issues/19406))
+- Push Synapse docker images to Element OCI Registry. ([\#19420](https://github.com/element-hq/synapse/issues/19420))
+- Allow configuring the Rust HTTP client to use HTTP/2 only. ([\#19457](https://github.com/element-hq/synapse/issues/19457))
+- Correctly refuse to start if the Rust workspace config has changed and the Rust library has not been rebuilt. ([\#19470](https://github.com/element-hq/synapse/issues/19470))
+
+
+
+
# Synapse 1.147.1 (2026-02-12)
## Internal Changes
diff --git a/changelog.d/19365.feature b/changelog.d/19365.feature
deleted file mode 100644
index c35afdc179e..00000000000
--- a/changelog.d/19365.feature
+++ /dev/null
@@ -1 +0,0 @@
-Support sending and receiving [MSC4354 Sticky Event](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) metadata.
\ No newline at end of file
diff --git a/changelog.d/19406.misc b/changelog.d/19406.misc
deleted file mode 100644
index bfe8d6afb0f..00000000000
--- a/changelog.d/19406.misc
+++ /dev/null
@@ -1 +0,0 @@
-Add in-repo Complement tests so we can test Synapse specific behavior at an end-to-end level.
diff --git a/changelog.d/19420.misc b/changelog.d/19420.misc
deleted file mode 100644
index 7b3718ce1a1..00000000000
--- a/changelog.d/19420.misc
+++ /dev/null
@@ -1 +0,0 @@
-Push Synapse docker images to Element OCI Registry.
diff --git a/changelog.d/19429.removal b/changelog.d/19429.removal
deleted file mode 100644
index b52ca2423d4..00000000000
--- a/changelog.d/19429.removal
+++ /dev/null
@@ -1 +0,0 @@
-Remove support for [MSC3244: Room version capabilities](https://github.com/matrix-org/matrix-spec-proposals/pull/3244) as the MSC was rejected.
\ No newline at end of file
diff --git a/changelog.d/19435.doc b/changelog.d/19435.doc
deleted file mode 100644
index 4edf2f73dce..00000000000
--- a/changelog.d/19435.doc
+++ /dev/null
@@ -1 +0,0 @@
-Fix reference to the `experimental_features` section of config.
diff --git a/changelog.d/19457.misc b/changelog.d/19457.misc
deleted file mode 100644
index ccbb7f5b144..00000000000
--- a/changelog.d/19457.misc
+++ /dev/null
@@ -1 +0,0 @@
-Allow configuring the Rust HTTP client to use HTTP/2 only.
diff --git a/changelog.d/19470.misc b/changelog.d/19470.misc
deleted file mode 100644
index e253d89b17e..00000000000
--- a/changelog.d/19470.misc
+++ /dev/null
@@ -1 +0,0 @@
-Correctly refuse to start if the Rust workspace config has changed and the Rust library has not been rebuilt.
diff --git a/debian/changelog b/debian/changelog
index a6852dac5e3..105ea2fcec1 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+matrix-synapse-py3 (1.148.0~rc1) stable; urgency=medium
+
+ * New synapse release 1.148.0rc1.
+
+ -- Synapse Packaging team Tue, 17 Feb 2026 16:44:08 +0000
+
matrix-synapse-py3 (1.147.1) stable; urgency=medium
* New synapse release 1.147.1.
diff --git a/pyproject.toml b/pyproject.toml
index 8073f8ec44f..07455c57df5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
-version = "1.147.1"
+version = "1.148.0rc1"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml
index 99c9b6e9bd1..42e9ed87dda 100644
--- a/schema/synapse-config.schema.yaml
+++ b/schema/synapse-config.schema.yaml
@@ -1,5 +1,5 @@
$schema: https://element-hq.github.io/synapse/latest/schema/v1/meta.schema.json
-$id: https://element-hq.github.io/synapse/schema/synapse/v1.147/synapse-config.schema.json
+$id: https://element-hq.github.io/synapse/schema/synapse/v1.148/synapse-config.schema.json
type: object
properties:
modules:
From 32c5f01bcb6e7070bfb014569e9979de3df26959 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Feb 2026 18:33:08 +0100
Subject: [PATCH 027/121] Minor reword
---
CHANGES.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGES.md b/CHANGES.md
index e4bec3aa41c..e52ca3ed30f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -6,7 +6,7 @@
## Improved Documentation
-- Fix reference to the `experimental_features` section of config. ([\#19435](https://github.com/element-hq/synapse/issues/19435))
+- Fix reference to the `experimental_features` section of the configuration manual documentation. ([\#19435](https://github.com/element-hq/synapse/issues/19435))
## Deprecations and Removals
From b80774efb2ea5ae780ab751aad5de52ac7a2b0de Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Tue, 17 Feb 2026 13:15:57 -0600
Subject: [PATCH 028/121] Better instrument `JoinRoomAliasServlet` with tracing
(#19461)
So we can better see why it decides to do a local vs remote join.
Spawning from [investigating a join issue on `matrix.org`](https://matrix.to/#/!SGNQGPGUwtcPBUotTL:matrix.org/$Odvd47QtkRscxilzkhcFOsDZWNvJUSEhSrD8GpukKWo?via=jki.re&via=element.io&via=matrix.org).
---
changelog.d/19461.misc | 1 +
synapse/handlers/room_member.py | 24 +++++++++++++++++++++---
synapse/handlers/room_member_worker.py | 5 +++++
3 files changed, 27 insertions(+), 3 deletions(-)
create mode 100644 changelog.d/19461.misc
diff --git a/changelog.d/19461.misc b/changelog.d/19461.misc
new file mode 100644
index 00000000000..9c444ffe3c9
--- /dev/null
+++ b/changelog.d/19461.misc
@@ -0,0 +1 @@
+Better instrument `JoinRoomAliasServlet` with tracing.
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index a8935fded65..0c6be727169 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -49,6 +49,7 @@
from synapse.handlers.state_deltas import MatchChange, StateDeltasHandler
from synapse.handlers.worker_lock import NEW_EVENT_DURING_PURGE_LOCK_NAME
from synapse.logging import opentracing
+from synapse.logging.opentracing import SynapseTags, set_tag, tag_args, trace
from synapse.metrics import SERVER_NAME_LABEL, event_processing_positions
from synapse.replication.http.push import ReplicationCopyPusherRestServlet
from synapse.storage.databases.main.state_deltas import StateDelta
@@ -390,6 +391,7 @@ async def ratelimit_invite(
if requester is not None:
await self._invites_per_issuer_limiter.ratelimit(requester)
+ @trace
async def _local_membership_update(
self,
*,
@@ -1221,6 +1223,8 @@ async def check_for_any_membership_in_room(
if result is None or result == (None, None):
raise AuthError(403, f"User {user_id} has no membership in room {room_id}")
+ @trace
+ @tag_args
async def _should_perform_remote_join(
self,
user_id: str,
@@ -1275,6 +1279,12 @@ async def _should_perform_remote_join(
prev_member_event = await self.store.get_event(prev_member_event_id)
previous_membership = prev_member_event.membership
+ # Interesting because it's used in the logic below to make decisions
+ set_tag(
+ SynapseTags.RESULT_PREFIX + "previous_membership",
+ str(previous_membership),
+ )
+
# If we are not fully joined yet, and the target is not already in the room,
# let's do a remote join so another server with the full state can validate
# that the user has not been banned for example.
@@ -1315,15 +1325,19 @@ async def _should_perform_remote_join(
state_key: event_map[event_id]
for state_key, event_id in state_before_join.items()
}
- allowed_servers = get_servers_from_users(
+ servers_that_can_issue_invite = get_servers_from_users(
get_users_which_can_issue_invite(current_state)
)
+ set_tag(
+ SynapseTags.RESULT_PREFIX + "servers_that_can_issue_invite",
+ str(servers_that_can_issue_invite),
+ )
# If the local server is not one of allowed servers, then a remote
# join must be done. Return the list of prospective servers based on
# which can issue invites.
- if self.hs.hostname not in allowed_servers:
- return True, list(allowed_servers)
+ if self.hs.hostname not in servers_that_can_issue_invite:
+ return True, list(servers_that_can_issue_invite)
# Ensure the member should be allowed access via membership in a room.
await self.event_auth_handler.check_restricted_join_rules(
@@ -1897,6 +1911,7 @@ async def _is_local_room_too_complex(self, room_id: str) -> bool:
return complexity["v1"] > max_complexity
+ @trace
async def _remote_join(
self,
requester: Requester,
@@ -1975,6 +1990,7 @@ async def _remote_join(
return event_id, stream_id
+ @trace
async def remote_reject_invite(
self,
invite_event_id: str,
@@ -2012,6 +2028,7 @@ async def remote_reject_invite(
invite_event, txn_id, requester, content
)
+ @trace
async def remote_rescind_knock(
self,
knock_event_id: str,
@@ -2124,6 +2141,7 @@ async def _generate_local_out_of_band_leave(
return result_event.event_id, result_event.internal_metadata.stream_ordering
+ @trace
async def remote_knock(
self,
requester: Requester,
diff --git a/synapse/handlers/room_member_worker.py b/synapse/handlers/room_member_worker.py
index b56519ab0a1..da635255980 100644
--- a/synapse/handlers/room_member_worker.py
+++ b/synapse/handlers/room_member_worker.py
@@ -23,6 +23,7 @@
from typing import TYPE_CHECKING
from synapse.handlers.room_member import NoKnownServersError, RoomMemberHandler
+from synapse.logging.opentracing import trace
from synapse.replication.http.membership import (
ReplicationRemoteJoinRestServlet as ReplRemoteJoin,
ReplicationRemoteKnockRestServlet as ReplRemoteKnock,
@@ -48,6 +49,7 @@ def __init__(self, hs: "HomeServer"):
self._remote_rescind_client = ReplRescindKnock.make_client(hs)
self._notify_change_client = ReplJoinedLeft.make_client(hs)
+ @trace
async def _remote_join(
self,
requester: Requester,
@@ -70,6 +72,7 @@ async def _remote_join(
return ret["event_id"], ret["stream_id"]
+ @trace
async def remote_reject_invite(
self,
invite_event_id: str,
@@ -90,6 +93,7 @@ async def remote_reject_invite(
)
return ret["event_id"], ret["stream_id"]
+ @trace
async def remote_rescind_knock(
self,
knock_event_id: str,
@@ -118,6 +122,7 @@ async def remote_rescind_knock(
)
return ret["event_id"], ret["stream_id"]
+ @trace
async def remote_knock(
self,
requester: Requester,
From b2778dae700d89e5c048b0015275f29044de0522 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Wed, 18 Feb 2026 11:37:09 -0600
Subject: [PATCH 029/121] Fix Complement CI not running against the code from
our PRs (remote images being chosen over local) (#19475)
Fix remote images being chosen over the local ones we just built with
Complement in CI (any Docker environment using the `containerd` image
store). This problem means that Complement jobs in CI don't actually
test against the code from the PR (since 2026-02-10).
This PR approaches the problem the same way that @AndrewFerr proposed in
https://github.com/element-hq/synapse/pull/18210. This is better than
the alternative listed below as we can just make our code compatible
with whatever image store is being used.
### Problem
Spawning from
https://github.com/element-hq/synapse/pull/19460#discussion_r2818760635
where we found that our Complement jobs in CI don't actually test
against the code from the PR at the moment.
This is caused by a change in Docker Engine 29.0.0:
> `containerd` image store is now the default for **fresh installs**.
This doesn't apply to daemons configured with `userns-remap` (see
[moby#47377](https://github.com/moby/moby/issues/47377)).
>
> *-- 29.0.0 (2025-11-10),
https://docs.docker.com/engine/release-notes/29/#2900*
And our `ubuntu-latest` GitHub runner (`Current runner version:
'2.331.0'`)
[points](https://github.com/actions/runner-images/blob/ubuntu24/20260209.23/images/ubuntu/Ubuntu2404-Readme.md)
to using Docker client/server `29.1.5` :dart:
This Docker version bump happened on
https://github.com/actions/runner-images/commit/416418df15a48321bdf51c3978c07a84a1873c75
(2026-02-10) (`28.0.4` -> `29.1.5`). Specific PR:
https://github.com/actions/runner-images/pull/13633
---
I found this because I reviewed and remembered
https://github.com/element-hq/synapse/pull/18210 was a thing that
@AndrewFerr ran into. And then running `dockers system prune` also
revealed the problematic `containerd` in CI. Checking the Docker
changelogs, I found the new default culprit and then could trace down
where the GitHub runners made the dependency update.
---------
Co-authored-by: Andrew Ferrazzutti
---
changelog.d/19475.misc | 1 +
docker/README-testing.md | 8 ++---
scripts-dev/complement.sh | 71 ++++++++++++++++++++++++++-------------
3 files changed, 53 insertions(+), 27 deletions(-)
create mode 100644 changelog.d/19475.misc
diff --git a/changelog.d/19475.misc b/changelog.d/19475.misc
new file mode 100644
index 00000000000..ea2774dcb15
--- /dev/null
+++ b/changelog.d/19475.misc
@@ -0,0 +1 @@
+Fix Complement CI not running against the code from our PRs.
diff --git a/docker/README-testing.md b/docker/README-testing.md
index 2ef8556d76f..aeeba58730a 100644
--- a/docker/README-testing.md
+++ b/docker/README-testing.md
@@ -31,23 +31,23 @@ release of Synapse, instead of your current checkout, you can skip this step. Fr
root of the repository:
```sh
-docker build -t matrixdotorg/synapse -f docker/Dockerfile .
+docker build -t localhost/synapse -f docker/Dockerfile .
```
Next, build the workerised Synapse docker image, which is a layer over the base
image.
```sh
-docker build -t matrixdotorg/synapse-workers -f docker/Dockerfile-workers .
+docker build -t localhost/synapse-workers --build-arg FROM=localhost/synapse -f docker/Dockerfile-workers .
```
Finally, build the multi-purpose image for Complement, which is a layer over the workers image.
```sh
-docker build -t complement-synapse -f docker/complement/Dockerfile docker/complement
+docker build -t localhost/complement-synapse -f docker/complement/Dockerfile --build-arg FROM=localhost/synapse-workers docker/complement
```
-This will build an image with the tag `complement-synapse`, which can be handed to
+This will build an image with the tag `localhost/complement-synapse`, which can be handed to
Complement for testing via the `COMPLEMENT_BASE_IMAGE` environment variable. Refer to
[Complement's documentation](https://github.com/matrix-org/complement/#running) for
how to run the tests, as well as the various available command line flags.
diff --git a/scripts-dev/complement.sh b/scripts-dev/complement.sh
index dc2262a2a35..9a18a621ee9 100755
--- a/scripts-dev/complement.sh
+++ b/scripts-dev/complement.sh
@@ -35,6 +35,26 @@
# Exit if a line returns a non-zero exit code
set -e
+# Tag local builds with a dummy registry namespace so that later builds may reference
+# them exactly instead of accidentally pulling from a remote registry.
+#
+# This is important as some storage drivers/types prefer remote images over local
+# (`containerd`) which causes problems as we're testing against some remote image that
+# doesn't include all of the changes that we're trying to test (be it locally or in a PR
+# in CI). This is spawning from a real-world problem where the GitHub runners were
+# updated to use Docker Engine 29.0.0+ which uses `containerd` by default for new
+# installations.
+LOCAL_IMAGE_NAMESPACE=localhost
+
+# The image tags for how these images will be stored in the registry
+SYNAPSE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse"
+SYNAPSE_WORKERS_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse-workers"
+COMPLEMENT_SYNAPSE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/complement-synapse"
+
+SYNAPSE_EDITABLE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse-editable"
+SYNAPSE_WORKERS_EDITABLE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse-workers-editable"
+COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/complement-synapse-editable"
+
# Helper to emit annotations that collapse portions of the log in GitHub Actions
echo_if_github() {
if [[ -n "$GITHUB_WORKFLOW" ]]; then
@@ -53,7 +73,7 @@ Run the complement test suite on Synapse.
-f, --fast
Skip rebuilding the docker images, and just use the most recent
- 'complement-synapse:latest' image.
+ 'localhost/complement-synapse:latest' image.
Conflicts with --build-only.
--build-only
@@ -154,16 +174,16 @@ main() {
editable_mount="$(realpath .):/editable-src:z"
if [ -n "$rebuild_editable_synapse" ]; then
unset skip_docker_build
- elif $CONTAINER_RUNTIME inspect complement-synapse-editable &>/dev/null; then
+ elif $CONTAINER_RUNTIME inspect "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" &>/dev/null; then
# complement-synapse-editable already exists: see if we can still use it:
# - The Rust module must still be importable; it will fail to import if the Rust source has changed.
# - The Poetry lock file must be the same (otherwise we assume dependencies have changed)
# First set up the module in the right place for an editable installation.
- $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'cp' complement-synapse-editable -- /synapse_rust.abi3.so.bak /editable-src/synapse/synapse_rust.abi3.so
+ $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'cp' "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" -- /synapse_rust.abi3.so.bak /editable-src/synapse/synapse_rust.abi3.so
- if ($CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'python' complement-synapse-editable -c 'import synapse.synapse_rust' \
- && $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'diff' complement-synapse-editable --brief /editable-src/poetry.lock /poetry.lock.bak); then
+ if ($CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'python' "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" -c 'import synapse.synapse_rust' \
+ && $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'diff' "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" --brief /editable-src/poetry.lock /poetry.lock.bak); then
skip_docker_build=1
else
echo "Editable Synapse image is stale. Will rebuild."
@@ -177,42 +197,47 @@ main() {
# Build a special image designed for use in development with editable
# installs.
- $CONTAINER_RUNTIME build -t synapse-editable \
+ $CONTAINER_RUNTIME build \
+ -t "$SYNAPSE_EDITABLE_IMAGE_PATH" \
-f "docker/editable.Dockerfile" .
- $CONTAINER_RUNTIME build -t synapse-workers-editable \
- --build-arg FROM=synapse-editable \
+ $CONTAINER_RUNTIME build \
+ -t "$SYNAPSE_WORKERS_EDITABLE_IMAGE_PATH" \
+ --build-arg FROM="$SYNAPSE_EDITABLE_IMAGE_PATH" \
-f "docker/Dockerfile-workers" .
- $CONTAINER_RUNTIME build -t complement-synapse-editable \
- --build-arg FROM=synapse-workers-editable \
+ $CONTAINER_RUNTIME build \
+ -t "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" \
+ --build-arg FROM="$SYNAPSE_WORKERS_EDITABLE_IMAGE_PATH" \
-f "docker/complement/Dockerfile" "docker/complement"
# Prepare the Rust module
- $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'cp' complement-synapse-editable -- /synapse_rust.abi3.so.bak /editable-src/synapse/synapse_rust.abi3.so
+ $CONTAINER_RUNTIME run --rm -v $editable_mount --entrypoint 'cp' "$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH" -- /synapse_rust.abi3.so.bak /editable-src/synapse/synapse_rust.abi3.so
else
# Build the base Synapse image from the local checkout
echo_if_github "::group::Build Docker image: matrixdotorg/synapse"
- $CONTAINER_RUNTIME build -t matrixdotorg/synapse \
- --build-arg TEST_ONLY_SKIP_DEP_HASH_VERIFICATION \
- --build-arg TEST_ONLY_IGNORE_POETRY_LOCKFILE \
- -f "docker/Dockerfile" .
+ $CONTAINER_RUNTIME build \
+ -t "$SYNAPSE_IMAGE_PATH" \
+ --build-arg TEST_ONLY_SKIP_DEP_HASH_VERIFICATION \
+ --build-arg TEST_ONLY_IGNORE_POETRY_LOCKFILE \
+ -f "docker/Dockerfile" .
echo_if_github "::endgroup::"
# Build the workers docker image (from the base Synapse image we just built).
echo_if_github "::group::Build Docker image: matrixdotorg/synapse-workers"
- $CONTAINER_RUNTIME build -t matrixdotorg/synapse-workers -f "docker/Dockerfile-workers" .
+ $CONTAINER_RUNTIME build \
+ -t "$SYNAPSE_WORKERS_IMAGE_PATH" \
+ --build-arg FROM="$SYNAPSE_IMAGE_PATH" \
+ -f "docker/Dockerfile-workers" .
echo_if_github "::endgroup::"
# Build the unified Complement image (from the worker Synapse image we just built).
echo_if_github "::group::Build Docker image: complement/Dockerfile"
- $CONTAINER_RUNTIME build -t complement-synapse \
- `# This is the tag we end up pushing to the registry (see` \
- `# .github/workflows/push_complement_image.yml) so let's just label it now` \
- `# so people can reference it by the same name locally.` \
- -t ghcr.io/element-hq/synapse/complement-synapse \
+ $CONTAINER_RUNTIME build \
+ -t "$COMPLEMENT_SYNAPSE_IMAGE_PATH" \
+ --build-arg FROM="$SYNAPSE_WORKERS_IMAGE_PATH" \
-f "docker/complement/Dockerfile" "docker/complement"
echo_if_github "::endgroup::"
@@ -253,9 +278,9 @@ main() {
./tests/...
)
- export COMPLEMENT_BASE_IMAGE=complement-synapse
+ export COMPLEMENT_BASE_IMAGE="$COMPLEMENT_SYNAPSE_IMAGE_PATH"
if [ -n "$use_editable_synapse" ]; then
- export COMPLEMENT_BASE_IMAGE=complement-synapse-editable
+ export COMPLEMENT_BASE_IMAGE="$COMPLEMENT_SYNAPSE_EDITABLE_IMAGE_PATH"
export COMPLEMENT_HOST_MOUNTS="$editable_mount"
fi
From 04206aebdf2444f189a5744b5cb62d419ff83e8b Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Thu, 19 Feb 2026 09:57:25 -0600
Subject: [PATCH 030/121] Log `docker system info` in CI (#19480)
Follow-up to
https://github.com/element-hq/synapse/pull/19460#discussion_r2819139638
and https://github.com/element-hq/synapse/pull/19475
---
.github/workflows/tests.yml | 6 ++++++
changelog.d/19480.misc | 1 +
2 files changed, 7 insertions(+)
create mode 100644 changelog.d/19480.misc
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 81a72f1f688..98b47130a1c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -693,6 +693,12 @@ jobs:
with:
path: synapse
+ # Log Docker system info for debugging (compare with your local environment) and
+ # tracking GitHub runner changes over time (can easily compare a run from last
+ # week with the current one in question).
+ - run: docker system info
+ shell: bash
+
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
with:
diff --git a/changelog.d/19480.misc b/changelog.d/19480.misc
new file mode 100644
index 00000000000..60abe04962c
--- /dev/null
+++ b/changelog.d/19480.misc
@@ -0,0 +1 @@
+Log `docker system info` in CI so we have a plain record of how GitHub runners evolve over time.
From 0ac772f082bc3adca0a99495f6805e3ea31fbcb3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 15:42:16 +0100
Subject: [PATCH 031/121] Bump pillow from 12.0.0 to 12.1.1 (#19454)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.0.0 to
12.1.1.
Release notes
This release excludes some unnecessary things from the archive
published to
crates.io. Specifically, fuzzing data and various shell scripts are now
excluded. If you run into problems, please file an issue.
Improvements:
#1319:
Switch from a Cargo exclude list to an include
list, and exclude some
unnecessary stuff.
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Cargo.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 47f81eaef1d..15342020c90 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13,9 +13,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.100"
+version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arc-swap"
@@ -995,9 +995,9 @@ dependencies = [
[[package]]
name = "regex"
-version = "1.12.2"
+version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
From 45006524591258434119e1d46ba65f969481ce71 Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Fri, 20 Feb 2026 15:37:34 +0000
Subject: [PATCH 033/121] Rename the `test_disconnect` test helper so that
pytest doesn't see it as a test. (#19486)
This fixes one of the 2 blockers to using pytest instead of Trial (which
is not formally-motivated, but sometimes seems like an interesting idea
because
pytest has seen a lot of developer experience features that Trial
hasn't. It would also removes one more coupling to the Twisted
framework.)
---
The `test_` prefix to this test helper makes it appear as a test to
pytest.
We *can* set a `__test__ = False` attribute on the test, but it felt
cleaner to just rename it (as I also thought it would be a test from
that name!).
This was previously reported as:
https://github.com/element-hq/synapse/issues/18665
---------
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19486.misc | 1 +
tests/federation/transport/server/test__base.py | 6 +++---
tests/http/server/_base.py | 2 +-
tests/http/test_servlet.py | 6 +++---
tests/replication/http/test__base.py | 6 +++---
tests/test_server.py | 10 +++++-----
6 files changed, 16 insertions(+), 15 deletions(-)
create mode 100644 changelog.d/19486.misc
diff --git a/changelog.d/19486.misc b/changelog.d/19486.misc
new file mode 100644
index 00000000000..c0c2fef770c
--- /dev/null
+++ b/changelog.d/19486.misc
@@ -0,0 +1 @@
+Rename the `test_disconnect` test helper so that pytest doesn't see it as a test.
\ No newline at end of file
diff --git a/tests/federation/transport/server/test__base.py b/tests/federation/transport/server/test__base.py
index 00a9c2064c6..447d0a071eb 100644
--- a/tests/federation/transport/server/test__base.py
+++ b/tests/federation/transport/server/test__base.py
@@ -34,7 +34,7 @@
from synapse.util.ratelimitutils import FederationRateLimiter
from tests import unittest
-from tests.http.server._base import test_disconnect
+from tests.http.server._base import disconnect_and_assert
class CancellableFederationServlet(BaseFederationServlet):
@@ -94,7 +94,7 @@ def test_cancellable_disconnect(self) -> None:
# request won't be processed.
self.pump()
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=True,
@@ -114,7 +114,7 @@ def test_uncancellable_disconnect(self) -> None:
# request won't be processed.
self.pump()
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=False,
diff --git a/tests/http/server/_base.py b/tests/http/server/_base.py
index afa69d1b7b6..41dcc0e095e 100644
--- a/tests/http/server/_base.py
+++ b/tests/http/server/_base.py
@@ -59,7 +59,7 @@
T = TypeVar("T")
-def test_disconnect(
+def disconnect_and_assert(
reactor: MemoryReactorClock,
channel: FakeChannel,
expect_cancellation: bool,
diff --git a/tests/http/test_servlet.py b/tests/http/test_servlet.py
index 2f1c8f03c6c..389d3f2e6e3 100644
--- a/tests/http/test_servlet.py
+++ b/tests/http/test_servlet.py
@@ -37,7 +37,7 @@
from synapse.util.duration import Duration
from tests import unittest
-from tests.http.server._base import test_disconnect
+from tests.http.server._base import disconnect_and_assert
def make_request(content: bytes | JsonDict) -> Mock:
@@ -127,7 +127,7 @@ class TestRestServletCancellation(unittest.HomeserverTestCase):
def test_cancellable_disconnect(self) -> None:
"""Test that handlers with the `@cancellable` flag can be cancelled."""
channel = self.make_request("GET", "/sleep", await_result=False)
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=True,
@@ -137,7 +137,7 @@ def test_cancellable_disconnect(self) -> None:
def test_uncancellable_disconnect(self) -> None:
"""Test that handlers without the `@cancellable` flag cannot be cancelled."""
channel = self.make_request("POST", "/sleep", await_result=False)
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=False,
diff --git a/tests/replication/http/test__base.py b/tests/replication/http/test__base.py
index 1c7e7e997bb..58ea542befb 100644
--- a/tests/replication/http/test__base.py
+++ b/tests/replication/http/test__base.py
@@ -33,7 +33,7 @@
from synapse.util.duration import Duration
from tests import unittest
-from tests.http.server._base import test_disconnect
+from tests.http.server._base import disconnect_and_assert
class CancellableReplicationEndpoint(ReplicationEndpoint):
@@ -94,7 +94,7 @@ def test_cancellable_disconnect(self) -> None:
"""Test that handlers with the `@cancellable` flag can be cancelled."""
path = f"{REPLICATION_PREFIX}/{CancellableReplicationEndpoint.NAME}/"
channel = self.make_request("POST", path, await_result=False, content={})
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=True,
@@ -105,7 +105,7 @@ def test_uncancellable_disconnect(self) -> None:
"""Test that handlers without the `@cancellable` flag cannot be cancelled."""
path = f"{REPLICATION_PREFIX}/{UncancellableReplicationEndpoint.NAME}/"
channel = self.make_request("POST", path, await_result=False, content={})
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=False,
diff --git a/tests/test_server.py b/tests/test_server.py
index 2a36dd4b304..2326fafc75e 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -41,7 +41,7 @@
from synapse.util.duration import Duration
from tests import unittest
-from tests.http.server._base import test_disconnect
+from tests.http.server._base import disconnect_and_assert
from tests.server import (
FakeChannel,
FakeSite,
@@ -506,7 +506,7 @@ def test_cancellable_disconnect(self) -> None:
channel = make_request(
self.reactor, self.site, "GET", "/sleep", await_result=False
)
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=True,
@@ -518,7 +518,7 @@ def test_uncancellable_disconnect(self) -> None:
channel = make_request(
self.reactor, self.site, "POST", "/sleep", await_result=False
)
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=False,
@@ -540,7 +540,7 @@ def test_cancellable_disconnect(self) -> None:
channel = make_request(
self.reactor, self.site, "GET", "/sleep", await_result=False
)
- test_disconnect(
+ disconnect_and_assert(
self.reactor,
channel,
expect_cancellation=True,
@@ -552,6 +552,6 @@ def test_uncancellable_disconnect(self) -> None:
channel = make_request(
self.reactor, self.site, "POST", "/sleep", await_result=False
)
- test_disconnect(
+ disconnect_and_assert(
self.reactor, channel, expect_cancellation=False, expected_body=b"ok"
)
From 16245f055096e8d0ab33b62e5769ade4b1a375c3 Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Fri, 20 Feb 2026 15:52:29 +0000
Subject: [PATCH 034/121] Fix the 'Login as a user' Admin API not checking if
the user exists before issuing an access token. (#18518)
Fixes: #18503
---------
Signed-off-by: Olivier 'reivilibre
Co-authored-by: Quentin Gliech
---
changelog.d/18518.bugfix | 1 +
synapse/rest/admin/users.py | 7 +++++++
tests/rest/admin/test_user.py | 11 +++++++++++
3 files changed, 19 insertions(+)
create mode 100644 changelog.d/18518.bugfix
diff --git a/changelog.d/18518.bugfix b/changelog.d/18518.bugfix
new file mode 100644
index 00000000000..959528d7c84
--- /dev/null
+++ b/changelog.d/18518.bugfix
@@ -0,0 +1 @@
+Fix the 'Login as a user' Admin API not checking if the user exists before issuing an access token.
\ No newline at end of file
diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py
index ccd34d17d80..807a9cad5b2 100644
--- a/synapse/rest/admin/users.py
+++ b/synapse/rest/admin/users.py
@@ -1144,6 +1144,7 @@ def __init__(self, hs: "HomeServer"):
self.store = hs.get_datastores().main
self.auth = hs.get_auth()
self.auth_handler = hs.get_auth_handler()
+ self.admin_handler = hs.get_admin_handler()
self.is_mine_id = hs.is_mine_id
async def on_POST(
@@ -1158,6 +1159,12 @@ async def on_POST(
HTTPStatus.BAD_REQUEST, "Only local users can be logged in as"
)
+ # Validate user_id
+ UserID.from_string(user_id)
+ _user_info_dict = await self.store.get_user_by_id(user_id)
+ if not _user_info_dict:
+ raise NotFoundError("User not found")
+
body = parse_json_object_from_request(request, allow_empty_body=True)
valid_until_ms = body.get("valid_until_ms")
diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py
index 6d0584fa63e..72937df9a63 100644
--- a/tests/rest/admin/test_user.py
+++ b/tests/rest/admin/test_user.py
@@ -4288,6 +4288,17 @@ def test_not_admin(self) -> None:
self.assertEqual(403, channel.code, msg=channel.json_body)
+ def test_no_user(self) -> None:
+ """Try to log in as a user that doesn't exist."""
+ channel = self.make_request(
+ "POST",
+ "/_synapse/admin/v1/users/%s/login" % urllib.parse.quote("@ghost:test"),
+ b"{}",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(404, channel.code, msg=channel.json_body)
+ self.assertEqual(Codes.NOT_FOUND, channel.json_body["errcode"])
+
def test_send_event(self) -> None:
"""Test that sending event as a user works."""
# Create a room.
From b30607ccafcb7ba9ee3ee7b7b1c89128aecb38b6 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 24 Feb 2026 12:18:37 +0100
Subject: [PATCH 035/121] 1.148.0
---
CHANGES.md | 7 +++++++
debian/changelog | 6 ++++++
pyproject.toml | 2 +-
3 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/CHANGES.md b/CHANGES.md
index e52ca3ed30f..cae604adac6 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,10 @@
+# Synapse 1.148.0 (2026-02-24)
+
+No significant changes since 1.148.0rc1.
+
+
+
+
# Synapse 1.148.0rc1 (2026-02-17)
## Features
diff --git a/debian/changelog b/debian/changelog
index 105ea2fcec1..31fd948be1e 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+matrix-synapse-py3 (1.148.0) stable; urgency=medium
+
+ * New synapse release 1.148.0.
+
+ -- Synapse Packaging team Tue, 24 Feb 2026 11:17:49 +0000
+
matrix-synapse-py3 (1.148.0~rc1) stable; urgency=medium
* New synapse release 1.148.0rc1.
diff --git a/pyproject.toml b/pyproject.toml
index 07455c57df5..0b8dff90589 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
-version = "1.148.0rc1"
+version = "1.148.0"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
From bc15ed3c62c37a8904af24ab7af7f4438d5d48b6 Mon Sep 17 00:00:00 2001
From: Brad Murray
Date: Tue, 24 Feb 2026 15:18:52 -0500
Subject: [PATCH 036/121] DeviceHandler: Add a log line when we delete a device
(#19496)
Deleting devices should be fairly
rare, and if someone gets logged out it's helpful to grep logs for a
user id or device id and see where it died.
---
changelog.d/19496.misc | 1 +
synapse/handlers/device.py | 2 ++
2 files changed, 3 insertions(+)
create mode 100644 changelog.d/19496.misc
diff --git a/changelog.d/19496.misc b/changelog.d/19496.misc
new file mode 100644
index 00000000000..83c5d6987c0
--- /dev/null
+++ b/changelog.d/19496.misc
@@ -0,0 +1 @@
+Add a log line when we delete devices. Contributed by @bradtgmurray @ Beeper.
diff --git a/synapse/handlers/device.py b/synapse/handlers/device.py
index 4552667176f..29074f5e20c 100644
--- a/synapse/handlers/device.py
+++ b/synapse/handlers/device.py
@@ -290,6 +290,8 @@ async def delete_devices(self, user_id: str, device_ids: StrCollection) -> None:
user_id: The user to delete devices from.
device_ids: The list of device IDs to delete
"""
+ logger.info("Deleting devices %r for %r", list(device_ids), user_id)
+
to_device_stream_id = self._event_sources.get_current_token().to_device_key
try:
From ac3a1155113af8896ecf3f9fcfdf5cfe23b3e90c Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Wed, 25 Feb 2026 09:47:13 -0600
Subject: [PATCH 037/121] Log if we ever `gc.freeze()` (#19440)
Spawning from
https://github.com/element-hq/synapse-small-hosts/issues/348 where some
test appears to be flaky because some homeserver objects are frozen in
the garbage collector.
We set
[`freeze=False`](https://github.com/element-hq/synapse-small-hosts/blob/a9a6869aa9a67176bdddc3b8ae2d0de0996d8cf4/multi_synapse/app/shard.py#L319-L321)
in the [Synapse Pro for small
hosts](https://docs.element.io/latest/element-server-suite-pro/synapse-pro-for-small-hosts/overview/)
code but I just want to use this log to make extra sure this isn't being
run somehow. The follow-up here would be to see what else would cause
something to be frozen in the garbage collector.
---
changelog.d/19440.misc | 1 +
synapse/app/_base.py | 5 +++++
2 files changed, 6 insertions(+)
create mode 100644 changelog.d/19440.misc
diff --git a/changelog.d/19440.misc b/changelog.d/19440.misc
new file mode 100644
index 00000000000..77776011144
--- /dev/null
+++ b/changelog.d/19440.misc
@@ -0,0 +1 @@
+Add log to explain when and why we freeze objects in the garbage collector.
diff --git a/synapse/app/_base.py b/synapse/app/_base.py
index c64c41e9d27..7f4855f36bd 100644
--- a/synapse/app/_base.py
+++ b/synapse/app/_base.py
@@ -776,6 +776,11 @@ def log_shutdown() -> None:
#
# PyPy does not (yet?) implement gc.freeze()
if hasattr(gc, "freeze"):
+ logger.info(
+ "garbage collector: Freezing all allocated objects in the hopes that (almost) "
+ "everything currently allocated are things that will be used by the homeserver "
+ "for the rest of time. Doing so means less work each GC (hopefully)."
+ )
gc.collect()
gc.freeze()
From f78d011df11040987b1215f42942330c03b09502 Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith
Date: Wed, 25 Feb 2026 17:41:51 +0000
Subject: [PATCH 038/121] Experimental implementation of unstable MSC4388 for
Sign in with QR (#19127)
Co-authored-by: Olivier 'reivilibre'
---
changelog.d/19127.feature | 1 +
rust/src/lib.rs | 2 +
rust/src/msc4388_rendezvous/mod.rs | 370 +++++++++
rust/src/msc4388_rendezvous/session.rs | 153 ++++
synapse/config/experimental.py | 23 +-
synapse/rest/client/rendezvous.py | 50 +-
synapse/rest/client/versions.py | 4 +-
synapse/server.py | 5 +
synapse/synapse_rust/msc4388_rendezvous.pyi | 33 +
tests/rest/client/test_msc4388_rendezvous.py | 743 +++++++++++++++++++
10 files changed, 1381 insertions(+), 3 deletions(-)
create mode 100644 changelog.d/19127.feature
create mode 100644 rust/src/msc4388_rendezvous/mod.rs
create mode 100644 rust/src/msc4388_rendezvous/session.rs
create mode 100644 synapse/synapse_rust/msc4388_rendezvous.pyi
create mode 100644 tests/rest/client/test_msc4388_rendezvous.py
diff --git a/changelog.d/19127.feature b/changelog.d/19127.feature
new file mode 100644
index 00000000000..7dc3a49f368
--- /dev/null
+++ b/changelog.d/19127.feature
@@ -0,0 +1 @@
+Add experimental support for [MSC4388: Secure out-of-band channel for sign in with QR](https://github.com/matrix-org/matrix-spec-proposals/pull/4388).
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index fe880af2eae..83b8de7b64a 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -12,6 +12,7 @@ pub mod http;
pub mod http_client;
pub mod identifier;
pub mod matrix_const;
+pub mod msc4388_rendezvous;
pub mod push;
pub mod rendezvous;
pub mod segmenter;
@@ -55,6 +56,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
events::register_module(py, m)?;
http_client::register_module(py, m)?;
rendezvous::register_module(py, m)?;
+ msc4388_rendezvous::register_module(py, m)?;
segmenter::register_module(py, m)?;
Ok(())
diff --git a/rust/src/msc4388_rendezvous/mod.rs b/rust/src/msc4388_rendezvous/mod.rs
new file mode 100644
index 00000000000..bc9463639fd
--- /dev/null
+++ b/rust/src/msc4388_rendezvous/mod.rs
@@ -0,0 +1,370 @@
+/*
+ * This file is licensed under the Affero General Public License (AGPL) version 3.
+ *
+ * Copyright (C) 2026 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:
+ * .
+ */
+
+use std::{
+ collections::BTreeMap,
+ time::{Duration, SystemTime},
+};
+
+use http::StatusCode;
+use pyo3::{
+ pyclass, pymethods,
+ types::{PyAnyMethods, PyModule, PyModuleMethods},
+ Bound, IntoPyObject, Py, PyAny, PyResult, Python,
+};
+use serde::Deserialize;
+use ulid::Ulid;
+
+use self::session::Session;
+use crate::{
+ duration::SynapseDuration,
+ errors::{NotFoundError, SynapseError},
+ http::http_request_from_twisted,
+ msc4388_rendezvous::session::{GetResponse, PostResponse, PutResponse},
+ UnwrapInfallible,
+};
+
+mod session;
+
+#[pyclass]
+struct MSC4388RendezvousHandler {
+ clock: Py,
+ sessions: BTreeMap,
+ soft_limit: usize,
+ hard_limit: usize,
+ max_content_length: u64,
+ ttl: Duration,
+}
+
+impl MSC4388RendezvousHandler {
+ /// Check the length of the data parameter and throw error if invalid.
+ fn check_data_length(&self, data: &str) -> PyResult<()> {
+ let data_length = data.len() as u64;
+ if data_length > self.max_content_length {
+ return Err(SynapseError::new(
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "Payload too large".to_owned(),
+ "M_TOO_LARGE",
+ None,
+ None,
+ ));
+ }
+ Ok(())
+ }
+
+ /// Evict expired sessions and remove the oldest sessions until we're under the capacity.
+ fn evict(&mut self, now: SystemTime) {
+ // First remove all the entries which expired
+ self.sessions.retain(|_, session| !session.expired(now));
+
+ // Then we remove the oldest entries until we're under the soft limit
+ while self.sessions.len() > self.soft_limit {
+ self.sessions.pop_first();
+ }
+ }
+}
+
+#[derive(Deserialize)]
+pub struct PostRequest {
+ data: String,
+}
+
+#[derive(Deserialize)]
+pub struct PutRequest {
+ sequence_token: String,
+ data: String,
+}
+
+#[pymethods]
+impl MSC4388RendezvousHandler {
+ #[new]
+ #[pyo3(signature = (homeserver, /, soft_limit=100, hard_limit=200,max_content_length=4*1024, eviction_interval=60*1000, ttl=2*60*1000))]
+ fn new(
+ py: Python<'_>,
+ homeserver: &Bound<'_, PyAny>,
+ soft_limit: usize,
+ hard_limit: usize,
+ max_content_length: u64,
+ eviction_interval: u64,
+ ttl: u64,
+ ) -> PyResult> {
+ let clock = homeserver
+ .call_method0("get_clock")?
+ .into_pyobject(py)
+ .unwrap_infallible()
+ .unbind();
+
+ // Construct a Python object so that we can get a reference to the
+ // evict method and schedule it to run.
+ let self_ = Py::new(
+ py,
+ Self {
+ clock,
+ sessions: BTreeMap::new(),
+ soft_limit,
+ hard_limit,
+ max_content_length,
+ ttl: Duration::from_millis(ttl),
+ },
+ )?;
+
+ let eviction_duration = SynapseDuration::from_milliseconds(eviction_interval);
+
+ let evict = self_.getattr(py, "_evict")?;
+ homeserver.call_method0("get_clock")?.call_method(
+ "looping_call",
+ (evict, &eviction_duration),
+ None,
+ )?;
+
+ Ok(self_)
+ }
+
+ fn _evict(&mut self, py: Python<'_>) -> PyResult<()> {
+ let clock = self.clock.bind(py);
+ let now: u64 = clock.call_method0("time_msec")?.extract()?;
+ let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now);
+ self.evict(now);
+
+ Ok(())
+ }
+
+ fn handle_post(
+ &mut self,
+ py: Python<'_>,
+ twisted_request: &Bound<'_, PyAny>,
+ ) -> PyResult<(u8, PostResponse)> {
+ let clock = self.clock.bind(py);
+ let now: u64 = clock.call_method0("time_msec")?.extract()?;
+ let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now);
+
+ // We trigger an immediate eviction if we're at the hard limit
+ if self.sessions.len() >= self.hard_limit {
+ self.evict(now);
+ }
+
+ // Generate a new ULID for the session from the current time.
+ let id = Ulid::from_datetime(now);
+
+ let request = http_request_from_twisted(twisted_request)?;
+ // parse JSON body
+ let post_request: PostRequest =
+ serde_json::from_slice(&request.into_body()).map_err(|_| {
+ SynapseError::new(
+ StatusCode::BAD_REQUEST,
+ "Invalid JSON in request body".to_owned(),
+ "M_INVALID_PARAM",
+ None,
+ None,
+ )
+ })?;
+
+ let data: String = post_request.data;
+ self.check_data_length(&data)?;
+
+ let session = Session::new(id, data, now, self.ttl);
+ let response = session.post_response(now);
+ self.sessions.insert(id, session);
+
+ Ok((200, response))
+ }
+
+ fn handle_get(
+ &mut self,
+ py: Python<'_>,
+ id: &str,
+ twisted_request: &Bound<'_, PyAny>,
+ ) -> PyResult<(u8, GetResponse)> {
+ let request = http_request_from_twisted(twisted_request)?;
+
+ // As per the MSC, we check the Sec-Fetch-* headers to ensure this request did not come from somewhere that will
+ // be rendered directly to the user, as the response may contain sensitive data. These headers are added by
+ // well behaved browsers so are helpful for protecting regular users.
+
+ // Sec-Fetch-Dest: https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header
+ //
+ // If the header is present then this must be "empty". All other values such as document, image etc.
+ // are considered potentially dangerous as they might be rendered to the user.
+ //
+ // Note that because we only ever return JSON, so it is unlikely that it could somehow be rendered as an image,
+ // video or other media.
+ let sec_fetch_dest: Option = request
+ .headers()
+ .get("sec-fetch-dest")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_owned());
+ if sec_fetch_dest.is_some() && sec_fetch_dest.as_deref() != Some("empty") {
+ return Err(SynapseError::new(
+ StatusCode::FORBIDDEN,
+ "Rendezvous content is not accessible from the request destination".to_owned(),
+ "M_FORBIDDEN",
+ None,
+ None,
+ ));
+ }
+
+ // Sec-Fetch-Mode: https://www.w3.org/TR/fetch-metadata/#sec-fetch-mode-header
+ //
+ // A request mode of "navigate" is not allowed as this indicates the request is being made by the
+ // browser to navigate to a URL, which could lead to the response being rendered directly to the user.
+ //
+ // Note that usually Sec-Fetch-Dest would be "document" in this case and so the request would be rejected earlier,
+ // but we check the mode just in case the destination is not set correctly.
+ let sec_fetch_mode: Option = request
+ .headers()
+ .get("sec-fetch-mode")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_owned());
+ if sec_fetch_mode.as_deref() == Some("navigate") {
+ return Err(SynapseError::new(
+ StatusCode::FORBIDDEN,
+ "Rendezvous content is not accessible via top-level navigation".to_owned(),
+ "M_FORBIDDEN",
+ None,
+ None,
+ ));
+ }
+
+ // Sec-Fetch-User: https://www.w3.org/TR/fetch-metadata/#sec-fetch-user-header
+ //
+ // If the request has a Sec-Fetch-User header with a value of "?1", this indicates that the
+ // request was triggered by user activation, such as a click.
+ //
+ // Note that usually Sec-Fetch-Mode would be "navigate" or the Sec-Fetch-Dest would be "document" in this case
+ // and so the request would be rejected earlier, but we check the user activation just in case those headers are
+ // not set correctly.
+ let sec_fetch_user: Option = request
+ .headers()
+ .get("sec-fetch-user")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_owned());
+ if sec_fetch_user.as_deref() == Some("?1") {
+ return Err(SynapseError::new(
+ StatusCode::FORBIDDEN,
+ "Rendezvous content is not accessible from requests with user activation"
+ .to_owned(),
+ "M_FORBIDDEN",
+ None,
+ None,
+ ));
+ }
+
+ // Sec-Fetch-Site: https://www.w3.org/TR/fetch-metadata/#sec-fetch-site-header
+ //
+ // "none" indicates the request did not originate from a web page
+ // (e.g. typed URL, bookmark, or browser extension), so we disallow it.
+ let sec_fetch_site: Option = request
+ .headers()
+ .get("sec-fetch-site")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_owned());
+ if sec_fetch_site.as_deref() == Some("none") {
+ return Err(SynapseError::new(
+ StatusCode::FORBIDDEN,
+ "Rendezvous content is not accessible from requests from user interaction"
+ .to_owned(),
+ "M_FORBIDDEN",
+ None,
+ None,
+ ));
+ }
+
+ let clock = self.clock.bind(py);
+ let now: u64 = clock.call_method0("time_msec")?.extract()?;
+ let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now);
+
+ let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?;
+ let session = self
+ .sessions
+ .get(&id)
+ .filter(|s| !s.expired(now))
+ .ok_or_else(NotFoundError::new)?;
+
+ Ok((200, session.get_response(now)))
+ }
+
+ fn handle_put(
+ &mut self,
+ py: Python<'_>,
+ id: &str,
+ twisted_request: &Bound<'_, PyAny>,
+ ) -> PyResult<(u8, PutResponse)> {
+ let request = http_request_from_twisted(twisted_request)?;
+ // parse JSON body
+ let put_request: PutRequest =
+ serde_json::from_slice(&request.into_body()).map_err(|_| {
+ SynapseError::new(
+ StatusCode::BAD_REQUEST,
+ "Invalid JSON in request body".to_owned(),
+ "M_INVALID_PARAM",
+ None,
+ None,
+ )
+ })?;
+
+ let sequence_token: String = put_request.sequence_token;
+
+ let data: String = put_request.data;
+
+ self.check_data_length(&data)?;
+
+ let clock = self.clock.bind(py);
+ let now: u64 = clock.call_method0("time_msec")?.extract()?;
+ let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now);
+
+ let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?;
+ let session = self
+ .sessions
+ .get_mut(&id)
+ .filter(|s| !s.expired(now))
+ .ok_or_else(NotFoundError::new)?;
+
+ if !session.sequence_token().eq(&sequence_token) {
+ return Err(SynapseError::new(
+ StatusCode::CONFLICT,
+ "sequence_token does not match".to_owned(),
+ "IO_ELEMENT_MSC4388_CONCURRENT_WRITE",
+ None,
+ None,
+ ));
+ }
+
+ session.update(data, now);
+
+ Ok((200, session.put_response()))
+ }
+
+ fn handle_delete(&mut self, id: &str) -> PyResult<(u8, ())> {
+ let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?;
+ let _session = self.sessions.remove(&id).ok_or_else(NotFoundError::new)?;
+
+ Ok((200, ()))
+ }
+}
+
+pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
+ let child_module = PyModule::new(py, "msc4388_rendezvous")?;
+
+ child_module.add_class::()?;
+
+ m.add_submodule(&child_module)?;
+
+ // We need to manually add the module to sys.modules to make `from
+ // synapse.synapse_rust import rendezvous` work.
+ py.import("sys")?
+ .getattr("modules")?
+ .set_item("synapse.synapse_rust.msc4388_rendezvous", child_module)?;
+
+ Ok(())
+}
diff --git a/rust/src/msc4388_rendezvous/session.rs b/rust/src/msc4388_rendezvous/session.rs
new file mode 100644
index 00000000000..467d1b5baf5
--- /dev/null
+++ b/rust/src/msc4388_rendezvous/session.rs
@@ -0,0 +1,153 @@
+/*
+ * This file is licensed under the Affero General Public License (AGPL) version 3.
+ *
+ * Copyright (C) 2026 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:
+ * .
+ */
+
+use std::time::{Duration, SystemTime};
+
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
+use pyo3::{Bound, IntoPyObject, PyAny, Python};
+use pythonize::{pythonize, PythonizeError};
+use serde::Serialize;
+use sha2::{Digest, Sha256};
+use ulid::Ulid;
+
+/// A single session, containing data, metadata, and expiry information.
+pub struct Session {
+ id: Ulid,
+ hash: [u8; 32],
+ data: String,
+ last_modified: SystemTime,
+ expires: SystemTime,
+}
+
+#[derive(Serialize)]
+pub struct PostResponse {
+ id: String,
+ sequence_token: String,
+ expires_in_ms: u64,
+}
+
+impl<'source> IntoPyObject<'source> for PostResponse {
+ type Target = PyAny;
+ type Output = Bound<'source, Self::Target>;
+ type Error = PythonizeError;
+
+ fn into_pyobject(self, py: Python<'source>) -> Result {
+ pythonize(py, &self)
+ }
+}
+
+#[derive(Serialize)]
+pub struct GetResponse {
+ data: String,
+ sequence_token: String,
+ expires_in_ms: u64,
+}
+
+impl<'source> IntoPyObject<'source> for GetResponse {
+ type Target = PyAny;
+ type Output = Bound<'source, Self::Target>;
+ type Error = PythonizeError;
+
+ fn into_pyobject(self, py: Python<'source>) -> Result {
+ pythonize(py, &self)
+ }
+}
+
+#[derive(Serialize)]
+pub struct PutResponse {
+ sequence_token: String,
+}
+
+impl<'source> IntoPyObject<'source> for PutResponse {
+ type Target = PyAny;
+ type Output = Bound<'source, Self::Target>;
+ type Error = PythonizeError;
+
+ fn into_pyobject(self, py: Python<'source>) -> Result {
+ pythonize(py, &self)
+ }
+}
+
+impl Session {
+ /// Create a new session with the given data and time-to-live.
+ pub fn new(id: Ulid, data: String, now: SystemTime, ttl: Duration) -> Self {
+ let hash = Self::compute_hash(&data, now);
+ Self {
+ id,
+ hash,
+ data,
+ expires: now + ttl,
+ last_modified: now,
+ }
+ }
+
+ /// Returns true if the session has expired at the given time.
+ pub fn expired(&self, now: SystemTime) -> bool {
+ self.expires <= now
+ }
+
+ /// Update the session with new data and last modified time.
+ pub fn update(&mut self, data: String, now: SystemTime) {
+ self.hash = Self::compute_hash(&data, now);
+ self.data = data;
+ self.last_modified = now;
+ }
+
+ /// Compute the hash of the data and timestamp.
+ fn compute_hash(data: &str, now: SystemTime) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(data);
+ let now_millis = now
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis();
+ hasher.update(now_millis.to_be_bytes());
+ hasher.finalize().into()
+ }
+
+ /// The sequence token for the session.
+ pub fn sequence_token(&self) -> String {
+ URL_SAFE_NO_PAD.encode(self.hash)
+ }
+
+ pub fn get_response(&self, now: SystemTime) -> GetResponse {
+ GetResponse {
+ data: self.data.clone(),
+ sequence_token: self.sequence_token(),
+ expires_in_ms: self
+ .expires
+ .duration_since(now)
+ .unwrap_or_default()
+ .as_millis() as u64,
+ }
+ }
+
+ pub fn post_response(&self, now: SystemTime) -> PostResponse {
+ PostResponse {
+ id: self.id.to_string(),
+ sequence_token: self.sequence_token(),
+ expires_in_ms: self
+ .expires
+ .duration_since(now)
+ .unwrap_or_default()
+ .as_millis() as u64,
+ }
+ }
+
+ pub fn put_response(&self) -> PutResponse {
+ PutResponse {
+ sequence_token: self.sequence_token(),
+ }
+ }
+}
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index b6c8b8c0625..05091ca6eb6 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -509,7 +509,8 @@ def read_config(
"msc4069_profile_inhibit_propagation", False
)
- # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code
+ # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code - 2024 version:
+ # See: https://github.com/element-hq/synapse/issues/19434
self.msc4108_enabled = experimental.get("msc4108_enabled", False)
self.msc4108_delegation_endpoint: str | None = experimental.get(
@@ -534,6 +535,26 @@ def read_config(
("experimental", "msc4108_delegation_endpoint"),
)
+ # MSC4388: Secure out-of-band channel for sign in with QR:
+ # See: https://github.com/element-hq/synapse/issues/19433
+ msc4388_mode = experimental.get("msc4388_mode", "off")
+
+ if msc4388_mode not in ["off", "public", "authenticated"]:
+ raise ConfigError(
+ "msc4388_mode must be one of 'off', 'public' or 'authenticated'",
+ ("experimental", "msc4388_mode"),
+ )
+ self.msc4388_enabled: bool = msc4388_mode != "off"
+ self.msc4388_requires_authentication: bool = msc4388_mode == "authenticated"
+
+ if self.msc4388_enabled and not (
+ config.get("matrix_authentication_service") or {}
+ ).get("enabled", False):
+ raise ConfigError(
+ "MSC4388 requires matrix_authentication_service to be enabled",
+ ("experimental", "msc4388_enabled"),
+ )
+
# MSC4133: Custom profile fields
self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False)
diff --git a/synapse/rest/client/rendezvous.py b/synapse/rest/client/rendezvous.py
index 08a449eefc7..bd9205fc5f0 100644
--- a/synapse/rest/client/rendezvous.py
+++ b/synapse/rest/client/rendezvous.py
@@ -21,7 +21,7 @@
import logging
from http.client import TEMPORARY_REDIRECT
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from synapse.http.server import HttpServer, respond_with_redirect
from synapse.http.servlet import RestServlet
@@ -68,9 +68,57 @@ def on_POST(self, request: SynapseRequest) -> None:
self._handler.handle_post(request)
+class MSC4388CreateRendezvousServlet(RestServlet):
+ PATTERNS = client_patterns(
+ "/io.element.msc4388/rendezvous$", releases=[], v1=False, unstable=True
+ )
+
+ def __init__(self, hs: "HomeServer") -> None:
+ super().__init__()
+ self._handler = hs.get_msc4388_rendezvous_handler()
+ self.auth = hs.get_auth()
+ self.require_authentication = (
+ hs.config.experimental.msc4388_requires_authentication
+ )
+
+ async def on_POST(self, request: SynapseRequest) -> tuple[int, Any]:
+ if self.require_authentication:
+ # This will raise if the user is not authenticated
+ await self.auth.get_user_by_req(request)
+ return self._handler.handle_post(request)
+
+
+class MSC4388UpdateRendezvousServlet(RestServlet):
+ PATTERNS = client_patterns(
+ "/io.element.msc4388/rendezvous/(?P[^/]+)$",
+ releases=[],
+ v1=False,
+ unstable=True,
+ )
+
+ def __init__(self, hs: "HomeServer") -> None:
+ super().__init__()
+ self._handler = hs.get_msc4388_rendezvous_handler()
+
+ def on_GET(self, request: SynapseRequest, rendezvous_id: str) -> tuple[int, Any]:
+ return self._handler.handle_get(rendezvous_id, request)
+
+ def on_PUT(self, request: SynapseRequest, rendezvous_id: str) -> tuple[int, Any]:
+ return self._handler.handle_put(rendezvous_id, request)
+
+ def on_DELETE(
+ self, _request: SynapseRequest, rendezvous_id: str
+ ) -> tuple[int, Any]:
+ return self._handler.handle_delete(rendezvous_id)
+
+
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
if hs.config.experimental.msc4108_enabled:
MSC4108RendezvousServlet(hs).register(http_server)
if hs.config.experimental.msc4108_delegation_endpoint is not None:
MSC4108DelegationRendezvousServlet(hs).register(http_server)
+
+ if hs.config.experimental.msc4388_enabled:
+ MSC4388CreateRendezvousServlet(hs).register(http_server)
+ MSC4388UpdateRendezvousServlet(hs).register(http_server)
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 89458495311..e443629faba 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -161,7 +161,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
"org.matrix.msc4069": self.config.experimental.msc4069_profile_inhibit_propagation,
# Allows clients to handle push for encrypted events.
"org.matrix.msc4028": self.config.experimental.msc4028_push_encrypted_events,
- # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code
+ # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code - 2024 version
"org.matrix.msc4108": (
self.config.experimental.msc4108_enabled
or (
@@ -169,6 +169,8 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
is not None
)
),
+ # MSC4388: Secure out-of-band channel for sign in with QR
+ "io.element.msc4388": (self.config.experimental.msc4388_enabled),
# MSC4140: Delayed events
"org.matrix.msc4140": bool(self.config.server.max_event_delay_ms),
# Simplified sliding sync
diff --git a/synapse/server.py b/synapse/server.py
index e6337c379b2..8bf19f11b5d 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -174,6 +174,7 @@
from synapse.storage import Databases
from synapse.storage.controllers import StorageControllers
from synapse.streams.events import EventSources
+from synapse.synapse_rust.msc4388_rendezvous import MSC4388RendezvousHandler
from synapse.synapse_rust.rendezvous import RendezvousHandler
from synapse.types import DomainSpecificString, ISynapseReactor
from synapse.util import SYNAPSE_VERSION
@@ -1184,6 +1185,10 @@ def get_room_forgetter_handler(self) -> RoomForgetterHandler:
def get_rendezvous_handler(self) -> RendezvousHandler:
return RendezvousHandler(self)
+ @cache_in_self
+ def get_msc4388_rendezvous_handler(self) -> MSC4388RendezvousHandler:
+ return MSC4388RendezvousHandler(self)
+
@cache_in_self
def get_outbound_redis_connection(self) -> "ConnectionHandler":
"""
diff --git a/synapse/synapse_rust/msc4388_rendezvous.pyi b/synapse/synapse_rust/msc4388_rendezvous.pyi
new file mode 100644
index 00000000000..f8e064ef64f
--- /dev/null
+++ b/synapse/synapse_rust/msc4388_rendezvous.pyi
@@ -0,0 +1,33 @@
+# This file is licensed under the Affero General Public License (AGPL) version 3.
+#
+# Copyright (C) 2026 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:
+# .
+
+from typing import Any
+
+from twisted.web.iweb import IRequest
+
+from synapse.server import HomeServer
+
+class MSC4388RendezvousHandler:
+ def __init__(
+ self,
+ homeserver: HomeServer,
+ /,
+ soft_limit: int = 100, # On each background eviction run sessions will be removed until we're under this limit
+ hard_limit: int = 200, # If this limit is reached an immediate eviction will be triggered
+ max_content_length: int = 4 * 1024, # MSC4388 specifies maximum of 4KB
+ eviction_interval: int = 60 * 1000,
+ ttl: int = 2 * 60 * 1000, # MSC4388 specifies minimum of 120 seconds
+ ) -> None: ...
+ def handle_post(self, request: IRequest) -> tuple[int, Any]: ...
+ def handle_get(self, session_id: str, request: IRequest) -> tuple[int, Any]: ...
+ def handle_put(self, session_id: str, request: IRequest) -> tuple[int, Any]: ...
+ def handle_delete(self, session_id: str) -> tuple[int, Any]: ...
diff --git a/tests/rest/client/test_msc4388_rendezvous.py b/tests/rest/client/test_msc4388_rendezvous.py
new file mode 100644
index 00000000000..f16bb3f344a
--- /dev/null
+++ b/tests/rest/client/test_msc4388_rendezvous.py
@@ -0,0 +1,743 @@
+#
+# This file is licensed under the Affero General Public License (AGPL) version 3.
+#
+# Copyright (C) 2026 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:
+# .
+
+
+import json
+import urllib.parse
+from typing import Any, Mapping
+from unittest.mock import Mock
+
+from parameterized import parameterized
+
+from twisted.internet.testing import MemoryReactor
+
+from synapse.api.auth.mas import MasDelegatedAuth
+from synapse.rest import admin
+from synapse.rest.client import login, rendezvous
+from synapse.server import HomeServer
+from synapse.types import UserID
+from synapse.util.clock import Clock
+
+from tests import unittest
+from tests.unittest import checked_cast, override_config
+
+rz_endpoint = "/_matrix/client/unstable/io.element.msc4388/rendezvous"
+
+
+class RendezvousServletTestCase(unittest.HomeserverTestCase):
+ """
+ Test the experimental MSC4388 rendezvous endpoint.
+ """
+
+ servlets = [
+ admin.register_servlets,
+ login.register_servlets,
+ rendezvous.register_servlets,
+ ]
+
+ def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
+ self.hs = self.setup_test_homeserver()
+ return self.hs
+
+ def setup_mock_oauth(self) -> None:
+ """
+ This isn't a very elegant way to mock the OAuth API, but it works for our purposes.
+ """
+
+ self.auth = checked_cast(MasDelegatedAuth, self.hs.get_auth())
+
+ self._rust_client = Mock(spec=["post"])
+ self._rust_client.post = self._mock_oauth_response
+ self.auth._rust_http_client = self._rust_client
+
+ async def _mock_oauth_response(
+ self,
+ url: str,
+ response_limit: int,
+ headers: Mapping[str, str],
+ request_body: str,
+ ) -> bytes:
+ # get the token from the request body which is form encoded
+ parsed_body = urllib.parse.parse_qs(request_body)
+ token = parsed_body.get("token", [""])[0]
+
+ if not token.startswith("mock_token_"):
+ return bytes(json.dumps({"active": False}).encode("utf-8"))
+ token = token.replace("mock_token_", "")
+
+ username, device_id = token.split("_", 1)
+ user_id = UserID(username, self.hs.hostname)
+ store = self.hs.get_datastores().main
+
+ # Check th user exists in the store
+ user_info = await store.get_user_by_id(user_id=user_id.to_string())
+ if user_info is None:
+ return bytes(json.dumps({"active": False}).encode("utf-8"))
+
+ # Check the device exists in the store
+ device = await store.get_device(
+ user_id=user_id.to_string(), device_id=device_id
+ )
+ if device is None:
+ return bytes(json.dumps({"active": False}).encode("utf-8"))
+
+ return bytes(
+ json.dumps(
+ {
+ "active": True,
+ "scope": "urn:matrix:client:device:"
+ + device_id
+ + " urn:matrix:client:api:*",
+ "username": username,
+ }
+ ).encode("utf-8")
+ )
+
+ def register_oauth_user(self, username: str, device_id: str) -> str:
+ # Provision the user and the device
+ store = self.hs.get_datastores().main
+ user_id = UserID(username, self.hs.hostname)
+
+ self.get_success(store.register_user(user_id=user_id.to_string()))
+ self.get_success(
+ store.store_device(
+ user_id=user_id.to_string(),
+ device_id=device_id,
+ initial_device_display_name=None,
+ )
+ )
+ # Generate an access token for the device
+ return "mock_token_" + username + "_" + device_id
+
+ def test_disabled(self) -> None:
+ channel = self.make_request("POST", rz_endpoint, {}, access_token=None)
+ self.assertEqual(channel.code, 404)
+
+ @override_config(
+ {
+ "experimental_features": {
+ "msc4388_mode": "off",
+ },
+ }
+ )
+ def test_off(self) -> None:
+ channel = self.make_request("POST", rz_endpoint, {}, access_token=None)
+ self.assertEqual(channel.code, 404)
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_rendezvous_public(self) -> None:
+ """
+ Test the MSC4108 rendezvous endpoint, including:
+ - Creating a session
+ - Getting the data back
+ - Updating the data
+ - Deleting the data
+ - Sequence token handling
+ """
+ # We can post arbitrary data to the endpoint
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+ sequence_token = channel.json_body["sequence_token"]
+ expires_in_ms = channel.json_body["expires_in_ms"]
+ self.assertGreater(expires_in_ms, 0)
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can get the data back
+ # Advances clock by 100ms
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=bar")
+ self.assertEqual(channel.json_body["sequence_token"], sequence_token)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 100)
+
+ # We can update the data
+ # Advances clock by 100ms
+ channel = self.make_request(
+ "PUT",
+ session_endpoint,
+ {"sequence_token": sequence_token, "data": "foo=baz"},
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ old_sequence_token = sequence_token
+ new_sequence_token = channel.json_body["sequence_token"]
+
+ # If we try to update it again with the old etag, it should fail
+ # Advances clock by 100ms
+ channel = self.make_request(
+ "PUT",
+ session_endpoint,
+ {"sequence_token": old_sequence_token, "data": "bar=baz"},
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 409)
+ self.assertEqual(
+ channel.json_body["errcode"], "IO_ELEMENT_MSC4388_CONCURRENT_WRITE"
+ )
+
+ # We should get the updated data
+ # Advances clock by 100ms
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=baz")
+ self.assertEqual(channel.json_body["sequence_token"], new_sequence_token)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 400)
+
+ # We can delete the data
+ channel = self.make_request(
+ "DELETE",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+
+ # If we try to get the data again, it should fail
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 404)
+ self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "authenticated",
+ },
+ }
+ )
+ def test_rendezvous_requires_authentication(self) -> None:
+ """
+ Test the MSC4108 rendezvous endpoint when configured with the mode authenticated, including:
+ - Creating a session
+ - Getting the data back
+ - Updating the data
+ - Deleting the data
+ - Sequence token handling
+ """
+ self.setup_mock_oauth()
+ alice_token = self.register_oauth_user("alice", "device1")
+
+ # This should fail without authentication:
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 401)
+
+ # This should work as we are now authenticated
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=alice_token,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+ sequence_token = channel.json_body["sequence_token"]
+ expires_in_ms = channel.json_body["expires_in_ms"]
+ self.assertGreater(expires_in_ms, 0)
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can get the data back without authentication
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=bar")
+ self.assertEqual(channel.json_body["sequence_token"], sequence_token)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms)
+
+ # We can update the data without authentication
+ channel = self.make_request(
+ "PUT",
+ session_endpoint,
+ {"sequence_token": sequence_token, "data": "foo=baz"},
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ new_sequence_token = channel.json_body["sequence_token"]
+
+ # We should get the updated data without authentication
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=baz")
+ self.assertEqual(channel.json_body["sequence_token"], new_sequence_token)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 200)
+
+ # We can delete the data without authentication
+ channel = self.make_request(
+ "DELETE",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+
+ # If we try to get the data again, it should fail
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 404)
+ self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_expiration(self) -> None:
+ """
+ Test that entries are evicted after a TTL.
+ """
+ # Start a new session
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ session_endpoint = rz_endpoint + "/" + channel.json_body["id"]
+
+ # Sanity check that we can get the data back
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=bar")
+
+ # Advance the clock, TTL of entries is 2 minutes
+ self.reactor.advance(120)
+
+ # Get the data back, it should be gone
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 404)
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_capacity(self) -> None:
+ """
+ Test that the soft capacity limit is enforced on the rendezvous sessions, as old
+ entries are evicted at an interval when the limit is reached.
+ """
+ # Start a new session
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ session_endpoint = rz_endpoint + "/" + channel.json_body["id"]
+
+ # Sanity check that we can get the data back
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=bar")
+
+ # We advance the clock to make sure that this entry is the "lowest" in the session list
+ self.reactor.advance(1)
+
+ # Start a lot of new sessions
+ for _ in range(100):
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+
+ # Get the data back, it should still be there, as the eviction hasn't run yet
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+
+ # Advance the clock, as it will trigger the eviction
+ self.reactor.advance(59)
+
+ # Get the data back, it should be gone
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 404)
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_hard_capacity(self) -> None:
+ """
+ Test that the hard capacity limit is enforced on the rendezvous sessions, as old
+ entries are evicted immediately when the limit is reached.
+ """
+ # Start a new session
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ session_endpoint = rz_endpoint + "/" + channel.json_body["id"]
+ # We advance the clock to make sure that this entry is the "lowest" in the session list
+ self.reactor.advance(1)
+
+ # Sanity check that we can get the data back
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ self.assertEqual(channel.json_body["data"], "foo=bar")
+
+ # Start a lot of new sessions
+ for _ in range(200):
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+
+ # Get the data back, it should already be gone as we hit the hard limit
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 404)
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_data_type(self) -> None:
+ """
+ Test that the data field is restricted to string.
+ """
+ invalid_datas: list[Any] = [123214, ["asd"], {"asd": "asdsad"}, None]
+
+ # We cannot post invalid non-string data field values to the endpoint
+ for invalid_data in invalid_datas:
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": invalid_data},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 400)
+ self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM")
+
+ # Make a valid request
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "test"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+ sequence_token = channel.json_body["sequence_token"]
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can't update the data with invalid data
+ for invalid_data in invalid_datas:
+ channel = self.make_request(
+ "PUT",
+ session_endpoint,
+ {"sequence_token": sequence_token, "data": invalid_data},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 400)
+ self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM")
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_max_length(self) -> None:
+ """
+ Test that the data max length is restricted.
+ """
+ too_long_data = "a" * 5000 # MSC4108 specifies 4KB max length
+
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": too_long_data},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 413)
+ self.assertEqual(channel.json_body["errcode"], "M_TOO_LARGE")
+
+ # Make a valid request
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "test"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+ sequence_token = channel.json_body["sequence_token"]
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can't update the data with invalid data
+ channel = self.make_request(
+ "PUT",
+ session_endpoint,
+ {"sequence_token": sequence_token, "data": too_long_data},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 413)
+ self.assertEqual(channel.json_body["errcode"], "M_TOO_LARGE")
+
+ @parameterized.expand(
+ [
+ ("Sec-Fetch-Dest", "document"),
+ ("Sec-Fetch-Dest", "image"),
+ ("Sec-Fetch-Dest", "iframe"),
+ ("Sec-Fetch-Dest", "embed"),
+ ("Sec-Fetch-Dest", "video"),
+ ("Sec-Fetch-Mode", "navigate"),
+ ("Sec-Fetch-User", "?1"),
+ ("Sec-Fetch-Site", "none"),
+ ]
+ )
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_rendezvous_rejects_unsafe_get_requests(
+ self, header_name: str, header_value: str
+ ) -> None:
+ """
+ Tests that GET requests have the appropriate Sec-Fetch-* controls applied as per the MSC.
+ The mode is set to `public` but this doesn't actually matter.
+ """
+ # We can post arbitrary data to the endpoint
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can get the data back
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ custom_headers=[(header_name, header_value)],
+ )
+ self.assertEqual(channel.code, 403)
+ self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
+
+ @override_config(
+ {
+ "disable_registration": True,
+ "matrix_authentication_service": {
+ "enabled": True,
+ "secret": "secret_value",
+ "endpoint": "https://issuer",
+ },
+ "experimental_features": {
+ "msc4388_mode": "public",
+ },
+ }
+ )
+ def test_rendezvous_allows_from_browser_fetch(self) -> None:
+ """
+ We check that the GET policy does allow for an expected browser fetch
+ The mode is set to `public` but this doesn't actually matter.
+ """
+ # We can post arbitrary data to the endpoint
+ channel = self.make_request(
+ "POST",
+ rz_endpoint,
+ {"data": "foo=bar"},
+ access_token=None,
+ )
+ self.assertEqual(channel.code, 200)
+ rendezvous_id = channel.json_body["id"]
+
+ session_endpoint = rz_endpoint + f"/{rendezvous_id}"
+
+ # We can get the data back
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ )
+
+ self.assertEqual(channel.code, 200)
+
+ # Test for a typical browser fetch from a client hosted on a different origin
+ channel = self.make_request(
+ "GET",
+ session_endpoint,
+ access_token=None,
+ custom_headers=[
+ ("Sec-Fetch-Dest", "empty"),
+ ("Sec-Fetch-Mode", "cors"),
+ ("Sec-Fetch-Site", "cross-site"),
+ ],
+ )
+
+ self.assertEqual(channel.code, 200)
From 2c73e8daef1c50abe9741f1fa321a34f9fdc5909 Mon Sep 17 00:00:00 2001
From: Erik Johnston
Date: Thu, 26 Feb 2026 22:41:06 +0100
Subject: [PATCH 039/121] Allow long lived syncs to be cancelled if client has
gone away (#19499)
---
changelog.d/19499.misc | 1 +
pyproject.toml | 6 +
synapse/api/auth/mas.py | 3 +-
synapse/api/auth/msc3861_delegated.py | 3 +-
synapse/appservice/api.py | 3 +-
synapse/config/cache.py | 9 +-
synapse/federation/federation_server.py | 6 +-
synapse/handlers/room.py | 2 +-
synapse/handlers/room_list.py | 3 +-
synapse/handlers/sync.py | 7 +-
synapse/handlers/typing.py | 4 +-
synapse/replication/http/_base.py | 2 +-
synapse/rest/client/sync.py | 2 +
synapse/util/async_helpers.py | 70 ++++-
synapse/util/caches/response_cache.py | 152 +++++++++-
synapse/util/clock.py | 16 +-
synapse/util/wheel_timer.py | 12 +-
tests/rest/client/test_sync.py | 63 +++++
tests/util/caches/test_response_cache.py | 345 ++++++++++++++++++++++-
tests/util/test_async_helpers.py | 22 +-
tests/util/test_wheel_timer.py | 11 +-
21 files changed, 703 insertions(+), 39 deletions(-)
create mode 100644 changelog.d/19499.misc
diff --git a/changelog.d/19499.misc b/changelog.d/19499.misc
new file mode 100644
index 00000000000..c2641c1e58d
--- /dev/null
+++ b/changelog.d/19499.misc
@@ -0,0 +1 @@
+Cancel long-running sync requests if the client has gone away.
diff --git a/pyproject.toml b/pyproject.toml
index 0b8dff90589..2d55d3c7a59 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -411,6 +411,12 @@ indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
+[tool.ruff.lint.flake8-bugbear]
+extend-immutable-calls = [
+ # Durations are immutable
+ "synapse.util.duration.Duration",
+]
+
[tool.maturin]
manifest-path = "rust/Cargo.toml"
module-name = "synapse.synapse_rust"
diff --git a/synapse/api/auth/mas.py b/synapse/api/auth/mas.py
index c4cca3723c6..79c15a53296 100644
--- a/synapse/api/auth/mas.py
+++ b/synapse/api/auth/mas.py
@@ -45,6 +45,7 @@
from synapse.types import JsonDict, Requester, UserID, create_requester
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
from synapse.util.caches.response_cache import ResponseCache, ResponseCacheContext
+from synapse.util.duration import Duration
from synapse.util.json import json_decoder
from . import introspection_response_timer
@@ -139,7 +140,7 @@ def __init__(self, hs: "HomeServer"):
clock=self._clock,
name="mas_token_introspection",
server_name=self.server_name,
- timeout_ms=120_000,
+ timeout=Duration(minutes=2),
# don't log because the keys are access tokens
enable_logging=False,
)
diff --git a/synapse/api/auth/msc3861_delegated.py b/synapse/api/auth/msc3861_delegated.py
index 7999d6e4598..27ab4af8056 100644
--- a/synapse/api/auth/msc3861_delegated.py
+++ b/synapse/api/auth/msc3861_delegated.py
@@ -49,6 +49,7 @@
from synapse.types import Requester, UserID, create_requester
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
from synapse.util.caches.response_cache import ResponseCache, ResponseCacheContext
+from synapse.util.duration import Duration
from synapse.util.json import json_decoder
from . import introspection_response_timer
@@ -205,7 +206,7 @@ def __init__(self, hs: "HomeServer"):
clock=self._clock,
name="token_introspection",
server_name=self.server_name,
- timeout_ms=120_000,
+ timeout=Duration(minutes=2),
# don't log because the keys are access tokens
enable_logging=False,
)
diff --git a/synapse/appservice/api.py b/synapse/appservice/api.py
index 71094de9bed..2bbf77a352c 100644
--- a/synapse/appservice/api.py
+++ b/synapse/appservice/api.py
@@ -46,6 +46,7 @@
from synapse.metrics import SERVER_NAME_LABEL
from synapse.types import DeviceListUpdates, JsonDict, JsonMapping, ThirdPartyInstanceID
from synapse.util.caches.response_cache import ResponseCache
+from synapse.util.duration import Duration
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -132,7 +133,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="as_protocol_meta",
server_name=self.server_name,
- timeout_ms=HOUR_IN_MS,
+ timeout=Duration(hours=1),
)
def _get_headers(self, service: "ApplicationService") -> dict[bytes, list[bytes]]:
diff --git a/synapse/config/cache.py b/synapse/config/cache.py
index c9ce826e1a2..ac94b17ff6e 100644
--- a/synapse/config/cache.py
+++ b/synapse/config/cache.py
@@ -29,6 +29,7 @@
from synapse.types import JsonDict
from synapse.util.check_dependencies import check_requirements
+from synapse.util.duration import Duration
from ._base import Config, ConfigError
@@ -108,7 +109,7 @@ class CacheConfig(Config):
global_factor: float
track_memory_usage: bool
expiry_time_msec: int | None
- sync_response_cache_duration: int
+ sync_response_cache_duration: Duration
@staticmethod
def reset() -> None:
@@ -207,10 +208,14 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
min_cache_ttl = self.cache_autotuning.get("min_cache_ttl")
self.cache_autotuning["min_cache_ttl"] = self.parse_duration(min_cache_ttl)
- self.sync_response_cache_duration = self.parse_duration(
+ sync_response_cache_duration_ms = self.parse_duration(
cache_config.get("sync_response_cache_duration", "2m")
)
+ self.sync_response_cache_duration = Duration(
+ milliseconds=sync_response_cache_duration_ms
+ )
+
def resize_all_caches(self) -> None:
"""Ensure all cache sizes are up-to-date.
diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py
index b909f1e5956..1912d545f55 100644
--- a/synapse/federation/federation_server.py
+++ b/synapse/federation/federation_server.py
@@ -166,7 +166,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="fed_txn_handler",
server_name=self.server_name,
- timeout_ms=30000,
+ timeout=Duration(seconds=30),
)
self.transaction_actions = TransactionActions(self.store)
@@ -179,13 +179,13 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="state_resp",
server_name=self.server_name,
- timeout_ms=30000,
+ timeout=Duration(seconds=30),
)
self._state_ids_resp_cache: ResponseCache[tuple[str, str]] = ResponseCache(
clock=hs.get_clock(),
name="state_ids_resp",
server_name=self.server_name,
- timeout_ms=30000,
+ timeout=Duration(seconds=30),
)
self._federation_metrics_domains = (
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index e03a9123198..1c3489a00e3 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -185,7 +185,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="room_upgrade",
server_name=self.server_name,
- timeout_ms=FIVE_MINUTES_IN_MS,
+ timeout=Duration(minutes=5),
)
self._server_notices_mxid = hs.config.servernotices.server_notices_mxid
diff --git a/synapse/handlers/room_list.py b/synapse/handlers/room_list.py
index 6377931b392..b25fd0a1e71 100644
--- a/synapse/handlers/room_list.py
+++ b/synapse/handlers/room_list.py
@@ -44,6 +44,7 @@
from synapse.types import JsonDict, JsonMapping, ThirdPartyInstanceID
from synapse.util.caches.descriptors import _CacheContext, cached
from synapse.util.caches.response_cache import ResponseCache
+from synapse.util.duration import Duration
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -79,7 +80,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="remote_room_list",
server_name=self.server_name,
- timeout_ms=30 * 1000,
+ timeout=Duration(seconds=30),
)
async def get_local_public_room_list(
diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py
index 72e91d66ac4..2f405004de9 100644
--- a/synapse/handlers/sync.py
+++ b/synapse/handlers/sync.py
@@ -77,6 +77,7 @@
from synapse.util.caches.expiringcache import ExpiringCache
from synapse.util.caches.lrucache import LruCache
from synapse.util.caches.response_cache import ResponseCache, ResponseCacheContext
+from synapse.util.cancellation import cancellable
from synapse.util.metrics import Measure
from synapse.visibility import filter_and_transform_events_for_client
@@ -307,7 +308,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="sync",
server_name=self.server_name,
- timeout_ms=hs.config.caches.sync_response_cache_duration,
+ timeout=hs.config.caches.sync_response_cache_duration,
)
# ExpiringCache((User, Device)) -> LruCache(user_id => event_id)
@@ -367,6 +368,10 @@ async def wait_for_sync_for_user(
logger.debug("Returning sync response for %s", user_id)
return res
+ # TODO: We mark this as cancellable, and we have tests for it, but we
+ # haven't gone through and exhaustively checked that all the code paths in
+ # this method are actually cancellable.
+ @cancellable
async def _wait_for_sync_for_user(
self,
sync_config: SyncConfig,
diff --git a/synapse/handlers/typing.py b/synapse/handlers/typing.py
index e66396fecc2..6daf304432e 100644
--- a/synapse/handlers/typing.py
+++ b/synapse/handlers/typing.py
@@ -102,7 +102,7 @@ def __init__(self, hs: "HomeServer"):
self._room_typing: dict[str, set[str]] = {}
self._member_last_federation_poke: dict[RoomMember, int] = {}
- self.wheel_timer: WheelTimer[RoomMember] = WheelTimer(bucket_size=5000)
+ self.wheel_timer: WheelTimer[RoomMember] = WheelTimer()
self._latest_room_serial = 0
self._rooms_updated: set[str] = set()
@@ -120,7 +120,7 @@ def _reset(self) -> None:
self._rooms_updated = set()
self._member_last_federation_poke = {}
- self.wheel_timer = WheelTimer(bucket_size=5000)
+ self.wheel_timer = WheelTimer()
@wrap_as_background_process("typing._handle_timeouts")
async def _handle_timeouts(self) -> None:
diff --git a/synapse/replication/http/_base.py b/synapse/replication/http/_base.py
index 2bab9c2d713..87d6e808982 100644
--- a/synapse/replication/http/_base.py
+++ b/synapse/replication/http/_base.py
@@ -130,7 +130,7 @@ def __init__(self, hs: "HomeServer"):
clock=hs.get_clock(),
name="repl." + self.NAME,
server_name=self.server_name,
- timeout_ms=30 * 60 * 1000,
+ timeout=Duration(minutes=30),
)
# We reserve `instance_name` as a parameter to sending requests, so we
diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py
index 458bf08a19f..91f2f16771a 100644
--- a/synapse/rest/client/sync.py
+++ b/synapse/rest/client/sync.py
@@ -59,6 +59,7 @@
from synapse.types import JsonDict, Requester, SlidingSyncStreamToken, StreamToken
from synapse.types.rest.client import SlidingSyncBody
from synapse.util.caches.lrucache import LruCache
+from synapse.util.cancellation import cancellable
from synapse.util.json import json_decoder
from ._base import client_patterns, set_timeline_upper_limit
@@ -138,6 +139,7 @@ def __init__(self, hs: "HomeServer"):
cfg=hs.config.ratelimiting.rc_presence_per_user,
)
+ @cancellable
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# This will always be set by the time Twisted calls us.
assert request.args is not None
diff --git a/synapse/util/async_helpers.py b/synapse/util/async_helpers.py
index 818f8b1a69b..53fb24ec5b8 100644
--- a/synapse/util/async_helpers.py
+++ b/synapse/util/async_helpers.py
@@ -57,6 +57,7 @@
run_coroutine_in_background,
run_in_background,
)
+from synapse.util.cancellation import cancellable
from synapse.util.clock import Clock
from synapse.util.duration import Duration
@@ -83,6 +84,13 @@ def observe(self) -> "defer.Deferred[_T]":
"""
...
+ @abc.abstractmethod
+ def has_observers(self) -> bool:
+ """Returns True if there are any observers currently observing this
+ ObservableDeferred.
+ """
+ ...
+
class ObservableDeferred(Generic[_T], AbstractObservableDeferred[_T]):
"""Wraps a deferred object so that we can add observer deferreds. These
@@ -122,6 +130,11 @@ def callback(r: _T) -> _T:
for observer in observers:
try:
observer.callback(r)
+ except defer.CancelledError:
+ # We do not want to propagate cancellations to the original
+ # deferred, or to other observers, so we can just ignore
+ # this.
+ pass
except Exception as e:
logger.exception(
"%r threw an exception on .callback(%r), ignoring...",
@@ -145,6 +158,11 @@ def errback(f: Failure) -> Failure | None:
f.value.__failure__ = f
try:
observer.errback(f)
+ except defer.CancelledError:
+ # We do not want to propagate cancellations to the original
+ # deferred, or to other observers, so we can just ignore
+ # this.
+ pass
except Exception as e:
logger.exception(
"%r threw an exception on .errback(%r), ignoring...",
@@ -160,6 +178,7 @@ def errback(f: Failure) -> Failure | None:
deferred.addCallbacks(callback, errback)
+ @cancellable
def observe(self) -> "defer.Deferred[_T]":
"""Observe the underlying deferred.
@@ -169,7 +188,7 @@ def observe(self) -> "defer.Deferred[_T]":
"""
if not self._result:
assert isinstance(self._observers, list)
- d: "defer.Deferred[_T]" = defer.Deferred()
+ d: "defer.Deferred[_T]" = defer.Deferred(canceller=self._remove_observer)
self._observers.append(d)
return d
elif self._result[0]:
@@ -180,6 +199,12 @@ def observe(self) -> "defer.Deferred[_T]":
def observers(self) -> "Collection[defer.Deferred[_T]]":
return self._observers
+ def has_observers(self) -> bool:
+ """Returns True if there are any observers currently observing this
+ ObservableDeferred.
+ """
+ return bool(self._observers)
+
def has_called(self) -> bool:
return self._result is not None
@@ -204,6 +229,28 @@ def __repr__(self) -> str:
self._deferred,
)
+ def _remove_observer(self, observer: "defer.Deferred[_T]") -> None:
+ """Removes an observer from the list of observers.
+
+ Used as a canceller for the observer deferreds, so that if an observer
+ is cancelled it is removed from the list of observers.
+ """
+ if self._result is not None:
+ # The underlying deferred has already resolved, so the observer has
+ # already been resolved. Nothing to do.
+ return
+
+ assert isinstance(self._observers, list)
+ try:
+ self._observers.remove(observer)
+ except ValueError:
+ # The observer was not in the list. This can happen if the underlying
+ # deferred resolves at around the same time as we try to remove the
+ # observer. In this case, it's possible that we tried to remove the
+ # observer just after it was added to the list, but before it was
+ # resolved and removed from the list by the callback/errback above.
+ pass
+
T = TypeVar("T")
@@ -962,6 +1009,27 @@ def handle_cancel(new_deferred: "defer.Deferred[T]") -> None:
return new_deferred
+def observe_deferred(d: "defer.Deferred[T]") -> "defer.Deferred[T]":
+ """Returns a new `Deferred` that observes the given `Deferred`.
+
+ The returned `Deferred` will resolve with the same result as the given
+ `Deferred`, but will not "chain" on the deferred so that using the returned
+ deferred does not affect the given `Deferred` in any way.
+ """
+ new_deferred: "defer.Deferred[T]" = defer.Deferred()
+
+ def callback(r: T) -> T:
+ new_deferred.callback(r)
+ return r
+
+ def errback(f: Failure) -> Failure:
+ new_deferred.errback(f)
+ return f
+
+ d.addCallbacks(callback, errback)
+ return new_deferred
+
+
class AwakenableSleeper:
"""Allows explicitly waking up deferreds related to an entity that are
currently sleeping.
diff --git a/synapse/util/caches/response_cache.py b/synapse/util/caches/response_cache.py
index 0289e13f6a0..70cea9b77c0 100644
--- a/synapse/util/caches/response_cache.py
+++ b/synapse/util/caches/response_cache.py
@@ -39,10 +39,15 @@
start_active_span,
start_active_span_follows_from,
)
-from synapse.util.async_helpers import AbstractObservableDeferred, ObservableDeferred
+from synapse.util.async_helpers import (
+ ObservableDeferred,
+ delay_cancellation,
+)
from synapse.util.caches import EvictionReason, register_cache
+from synapse.util.cancellation import cancellable, is_function_cancellable
from synapse.util.clock import Clock
from synapse.util.duration import Duration
+from synapse.util.wheel_timer import WheelTimer
logger = logging.getLogger(__name__)
@@ -79,8 +84,8 @@ class ResponseCacheContext(Generic[KV]):
@attr.s(auto_attribs=True)
-class ResponseCacheEntry:
- result: AbstractObservableDeferred
+class ResponseCacheEntry(Generic[KV]):
+ result: ObservableDeferred[KV]
"""The (possibly incomplete) result of the operation.
Note that we continue to store an ObservableDeferred even after the operation
@@ -91,6 +96,15 @@ class ResponseCacheEntry:
opentracing_span_context: "opentracing.SpanContext | None"
"""The opentracing span which generated/is generating the result"""
+ cancellable: bool
+ """Whether the deferred is safe to be cancelled."""
+
+ last_observer_removed_time_ms: int | None = None
+ """The last time that an observer was removed from this entry.
+
+ Used to determine when to evict the entry if it has no observers.
+ """
+
class ResponseCache(Generic[KV]):
"""
@@ -98,6 +112,22 @@ class ResponseCache(Generic[KV]):
returned from the cache. This means that if the client retries the request
while the response is still being computed, that original response will be
used rather than trying to compute a new response.
+
+ If a timeout is not specified then the cache entry will be kept while the
+ wrapped function is still running, and will be removed immediately once it
+ completes.
+
+ If a timeout is specified then the cache entry will be kept for the duration
+ of the timeout after the wrapped function completes. If the wrapped function
+ is cancellable and during processing nothing waits on the result for longer
+ than the timeout then the wrapped function will be cancelled and the cache
+ entry will be removed.
+
+ This behaviour is useful for caching responses to requests which are
+ expensive to compute, but which may be retried by clients if they time out.
+ For example, /sync requests which may take a long time to compute, and which
+ clients will retry. However, if the client stops retrying for a while then
+ we want to stop processing the request and free up the resources.
"""
def __init__(
@@ -106,7 +136,7 @@ def __init__(
clock: Clock,
name: str,
server_name: str,
- timeout_ms: float = 0,
+ timeout: Duration | None = None,
enable_logging: bool = True,
):
"""
@@ -121,7 +151,7 @@ def __init__(
self._result_cache: dict[KV, ResponseCacheEntry] = {}
self.clock = clock
- self.timeout = Duration(milliseconds=timeout_ms)
+ self.timeout = timeout
self._name = name
self._metrics = register_cache(
@@ -133,6 +163,13 @@ def __init__(
)
self._enable_logging = enable_logging
+ self._prune_timer: WheelTimer[KV] | None = None
+ if self.timeout:
+ # Set up the timers for pruning inflight entries. The times here are
+ # how often we check for entries to prune.
+ self._prune_timer = WheelTimer(bucket_size=self.timeout / 10)
+ self.clock.looping_call(self._prune_inflight_entries, self.timeout / 10)
+
def size(self) -> int:
return len(self._result_cache)
@@ -172,6 +209,7 @@ def _set(
context: ResponseCacheContext[KV],
deferred: "defer.Deferred[RV]",
opentracing_span_context: "opentracing.SpanContext | None",
+ cancellable: bool,
) -> ResponseCacheEntry:
"""Set the entry for the given key to the given deferred.
@@ -183,13 +221,16 @@ def _set(
context: Information about the cache miss
deferred: The deferred which resolves to the result.
opentracing_span_context: An opentracing span wrapping the calculation
+ cancellable: Whether the deferred is safe to be cancelled
Returns:
The cache entry object.
"""
result = ObservableDeferred(deferred, consumeErrors=True)
key = context.cache_key
- entry = ResponseCacheEntry(result, opentracing_span_context)
+ entry = ResponseCacheEntry(
+ result, opentracing_span_context, cancellable=cancellable
+ )
self._result_cache[key] = entry
def on_complete(r: RV) -> RV:
@@ -233,6 +274,7 @@ def _entry_timeout(self, key: KV) -> None:
self._metrics.inc_evictions(EvictionReason.time)
self._result_cache.pop(key, None)
+ @cancellable
async def wrap(
self,
key: KV,
@@ -301,8 +343,44 @@ async def cb() -> RV:
return await callback(*args, **kwargs)
d = run_in_background(cb)
- entry = self._set(context, d, span_context)
- return await make_deferred_yieldable(entry.result.observe())
+ entry = self._set(
+ context, d, span_context, cancellable=is_function_cancellable(callback)
+ )
+ try:
+ return await make_deferred_yieldable(entry.result.observe())
+ except defer.CancelledError:
+ pass
+
+ # We've been cancelled.
+ #
+ # Since we've kicked off the background operation, we can't just
+ # give up and return here and need to wait for the background
+ # operation to stop. We don't want to stop the background process
+ # immediately to give a chance for retries to come in and wait for
+ # the result.
+ #
+ # Instead, we temporarily swallow the cancellation and mark the
+ # cache key as one to potentially timeout.
+
+ # Update the `last_observer_removed_time_ms` so that the pruning
+ # mechanism can kick in if needed.
+ now = self.clock.time_msec()
+ entry.last_observer_removed_time_ms = now
+ if self._prune_timer is not None and self.timeout:
+ self._prune_timer.insert(now, key, now + self.timeout.as_millis())
+
+ # Wait on the original deferred, which will continue to run in the
+ # background until it completes. We don't want to add an observer as
+ # this would prevent the entry from being pruned.
+ #
+ # Note that this deferred has been consumed by the
+ # ObservableDeferred, so we don't know what it will return. That
+ # doesn't matter as we just want to throw a CancelledError once it completes anyway.
+ try:
+ await make_deferred_yieldable(delay_cancellation(d))
+ except Exception:
+ pass
+ raise defer.CancelledError()
result = entry.result.observe()
if self._enable_logging:
@@ -320,4 +398,60 @@ async def cb() -> RV:
f"ResponseCache[{self._name}].wait",
contexts=(span_context,) if span_context else (),
):
- return await make_deferred_yieldable(result)
+ try:
+ return await make_deferred_yieldable(result)
+ except defer.CancelledError:
+ # If we're cancelled then we update the
+ # `last_observer_removed_time_ms` so that the pruning mechanism
+ # can kick in if needed.
+ now = self.clock.time_msec()
+ entry.last_observer_removed_time_ms = now
+ if self._prune_timer is not None and self.timeout:
+ self._prune_timer.insert(now, key, now + self.timeout.as_millis())
+ raise
+
+ def _prune_inflight_entries(self) -> None:
+ """Prune entries which have been in the cache for too long without
+ observers"""
+ assert self._prune_timer is not None
+ assert self.timeout is not None
+
+ now = self.clock.time_msec()
+ keys_to_check = self._prune_timer.fetch(now)
+
+ # Loop through the keys and check if they should be evicted. We evict
+ # entries which have no active observers, and which have been in the
+ # cache for longer than the timeout since the last observer was removed.
+ for key in keys_to_check:
+ entry = self._result_cache.get(key)
+ if not entry:
+ continue
+
+ if not entry.cancellable:
+ # this entry is not cancellable, so we should keep it in the cache until it completes.
+ continue
+
+ if entry.result.has_called():
+ # this entry has already completed, so we should have scheduled it for
+ # removal at the right time. We can just skip it here and wait for the
+ # scheduled call to remove it.
+ continue
+
+ if entry.result.has_observers():
+ # this entry has observers, so we should keep it in the cache for now.
+ continue
+
+ if entry.last_observer_removed_time_ms is None:
+ # this should never happen, but just in case, we should keep the entry
+ # in the cache until we have a valid last_observer_removed_time_ms to
+ # compare against.
+ continue
+
+ if now - entry.last_observer_removed_time_ms > self.timeout.as_millis():
+ self._metrics.inc_evictions(EvictionReason.time)
+ self._result_cache.pop(key, None)
+ try:
+ entry.result.cancel()
+ except Exception:
+ # we ignore exceptions from cancel, as it is best effort anyway.
+ pass
diff --git a/synapse/util/clock.py b/synapse/util/clock.py
index a3872d6f939..7232a1331c8 100644
--- a/synapse/util/clock.py
+++ b/synapse/util/clock.py
@@ -62,6 +62,16 @@
logging.setLoggerClass(original_logger_class)
+def _try_wakeup_deferred(d: Deferred) -> None:
+ """Try to wake up a deferred, but ignore any exceptions raised by the
+ callback. This is useful when we want to wake up a deferred that may have
+ already been cancelled, and we don't care about the result."""
+ try:
+ d.callback(None)
+ except Exception:
+ pass
+
+
class Clock:
"""
A Clock wraps a Twisted reactor and provides utilities on top of it.
@@ -114,7 +124,11 @@ async def sleep(self, duration: Duration) -> None:
with context.PreserveLoggingContext():
# We can ignore the lint here since this class is the one location callLater should
# be called.
- self._reactor.callLater(duration.as_secs(), d.callback, duration.as_secs()) # type: ignore[call-later-not-tracked]
+ self._reactor.callLater(
+ duration.as_secs(),
+ lambda _: _try_wakeup_deferred(d),
+ duration.as_secs(),
+ ) # type: ignore[call-later-not-tracked]
await d
def time(self) -> float:
diff --git a/synapse/util/wheel_timer.py b/synapse/util/wheel_timer.py
index c63faa96dfb..fe4622fe99b 100644
--- a/synapse/util/wheel_timer.py
+++ b/synapse/util/wheel_timer.py
@@ -23,6 +23,8 @@
import attr
+from synapse.util.duration import Duration
+
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=Hashable)
@@ -39,13 +41,13 @@ class WheelTimer(Generic[T]):
expired.
"""
- def __init__(self, bucket_size: int = 5000) -> None:
+ def __init__(self, bucket_size: Duration = Duration(seconds=5)) -> None:
"""
Args:
bucket_size: Size of buckets in ms. Corresponds roughly to the
accuracy of the timer.
"""
- self.bucket_size: int = bucket_size
+ self.bucket_size = bucket_size
self.entries: list[_Entry[T]] = []
def insert(self, now: int, obj: T, then: int) -> None:
@@ -56,8 +58,8 @@ def insert(self, now: int, obj: T, then: int) -> None:
obj: Object to be inserted
then: When to return the object strictly after.
"""
- then_key = int(then / self.bucket_size) + 1
- now_key = int(now / self.bucket_size)
+ then_key = int(then / self.bucket_size.as_millis()) + 1
+ now_key = int(now / self.bucket_size.as_millis())
if self.entries:
min_key = self.entries[0].end_key
@@ -100,7 +102,7 @@ def fetch(self, now: int) -> list[T]:
Returns:
List of objects that have timed out
"""
- now_key = int(now / self.bucket_size)
+ now_key = int(now / self.bucket_size.as_millis())
ret: list[T] = []
while self.entries and self.entries[0].end_key <= now_key:
diff --git a/tests/rest/client/test_sync.py b/tests/rest/client/test_sync.py
index fcbf3fd53cd..e6ada1adb27 100644
--- a/tests/rest/client/test_sync.py
+++ b/tests/rest/client/test_sync.py
@@ -41,6 +41,7 @@
from tests.federation.transport.test_knocking import (
KnockingStrippedStateEventHelperMixin,
)
+from tests.rest.client.test_rooms import make_request_with_cancellation_test
from tests.server import TimedOutException
logger = logging.getLogger(__name__)
@@ -1145,3 +1146,65 @@ def test_incremental_sync(self) -> None:
self.assertNotIn(self.excluded_room_id, channel.json_body["rooms"]["join"])
self.assertIn(self.included_room_id, channel.json_body["rooms"]["join"])
+
+
+class SyncCancellationTestCase(unittest.HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets,
+ login.register_servlets,
+ sync.register_servlets,
+ room.register_servlets,
+ ]
+
+ def test_initial_sync(self) -> None:
+ """Tests that an initial sync request can be cancelled."""
+ user_id = self.register_user("user", "password")
+ tok = self.login("user", "password")
+
+ # Populate the account with a few rooms
+ for _ in range(5):
+ room_id = self.helper.create_room_as(user_id, tok=tok)
+ self.helper.send(room_id, tok=tok)
+
+ channel = make_request_with_cancellation_test(
+ "test_initial_sync",
+ self.reactor,
+ self.site,
+ "GET",
+ "/_matrix/client/v3/sync",
+ token=tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.result["body"])
+
+ def test_incremental_sync(self) -> None:
+ """Tests that an incremental sync request can be cancelled."""
+ user_id = self.register_user("user", "password")
+ tok = self.login("user", "password")
+
+ # Populate the account with a few rooms
+ room_ids = []
+ for _ in range(5):
+ room_id = self.helper.create_room_as(user_id, tok=tok)
+ self.helper.send(room_id, tok=tok)
+ room_ids.append(room_id)
+
+ # Do an initial sync to get a since token.
+ channel = self.make_request("GET", "/sync", access_token=tok)
+ self.assertEqual(200, channel.code, msg=channel.result)
+ since = channel.json_body["next_batch"]
+
+ # Send some more messages to generate activity in the rooms.
+ for room_id in room_ids:
+ self.helper.send(room_id, tok=tok)
+
+ channel = make_request_with_cancellation_test(
+ "test_incremental_sync",
+ self.reactor,
+ self.site,
+ "GET",
+ f"/_matrix/client/v3/sync?since={since}&timeout=10000",
+ token=tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.result["body"])
diff --git a/tests/util/caches/test_response_cache.py b/tests/util/caches/test_response_cache.py
index def5c817db2..af427698901 100644
--- a/tests/util/caches/test_response_cache.py
+++ b/tests/util/caches/test_response_cache.py
@@ -19,6 +19,7 @@
#
#
+from functools import wraps
from unittest.mock import Mock
from parameterized import parameterized
@@ -26,6 +27,7 @@
from twisted.internet import defer
from synapse.util.caches.response_cache import ResponseCache, ResponseCacheContext
+from synapse.util.cancellation import cancellable
from synapse.util.duration import Duration
from tests.server import get_clock
@@ -48,15 +50,23 @@ def setUp(self) -> None:
def with_cache(self, name: str, ms: int = 0) -> ResponseCache:
return ResponseCache(
- clock=self.clock, name=name, server_name="test_server", timeout_ms=ms
+ clock=self.clock,
+ name=name,
+ server_name="test_server",
+ timeout=Duration(milliseconds=ms),
)
@staticmethod
async def instant_return(o: str) -> str:
return o
- async def delayed_return(self, o: str) -> str:
- await self.clock.sleep(Duration(seconds=1))
+ @cancellable
+ async def delayed_return(
+ self,
+ o: str,
+ duration: Duration = Duration(seconds=1), # noqa
+ ) -> str:
+ await self.clock.sleep(duration)
return o
def test_cache_hit(self) -> None:
@@ -223,3 +233,332 @@ async def non_caching(o: str, cache_context: ResponseCacheContext[int]) -> str:
self.assertCountEqual(
[], cache.keys(), "cache should not have the result now"
)
+
+ def test_cache_func_errors(self) -> None:
+ """If the callback raises an error, the error should be raised to all
+ callers and the result should not be cached"""
+ cache = self.with_cache("error_cache", ms=3000)
+
+ expected_error = Exception("oh no")
+
+ async def erring(o: str) -> str:
+ await self.clock.sleep(Duration(seconds=1))
+ raise expected_error
+
+ wrap_d = defer.ensureDeferred(cache.wrap(0, erring, "ignored"))
+ self.assertNoResult(wrap_d)
+
+ # a second call should also return a pending deferred
+ wrap2_d = defer.ensureDeferred(cache.wrap(0, erring, "ignored"))
+ self.assertNoResult(wrap2_d)
+
+ # let the call complete
+ self.reactor.advance(1)
+
+ # both results should have completed with the error
+ self.assertFailure(wrap_d, Exception)
+ self.assertFailure(wrap2_d, Exception)
+
+ def test_cache_cancel_first_wait(self) -> None:
+ """Test that cancellation of the deferred returned by wrap() on the
+ first call does not immediately cause a cancellation error to be raised
+ when its cancelled and the wrapped function continues execution (unless
+ it times out).
+ """
+ cache = self.with_cache("cancel_cache", ms=3000)
+
+ expected_result = "howdy"
+
+ wrap_d = defer.ensureDeferred(
+ cache.wrap(0, self.delayed_return, expected_result)
+ )
+
+ # cancel the deferred before it has a chance to return
+ wrap_d.cancel()
+
+ # The cancel should be ignored for now, and the inner function should
+ # still be running.
+ self.assertNoResult(wrap_d)
+
+ # Advance the clock until the inner function should have returned, but
+ # not long enough for the cache entry to have expired.
+ self.reactor.advance(2)
+
+ # The deferred we're waiting on should now return a cancelled error.
+ self.assertFailure(wrap_d, defer.CancelledError)
+
+ # However future callers should get the result.
+ wrap_d2 = defer.ensureDeferred(
+ cache.wrap(0, self.delayed_return, expected_result)
+ )
+ self.assertEqual(expected_result, self.successResultOf(wrap_d2))
+
+ def test_cache_cancel_first_wait_expire(self) -> None:
+ """Test that cancellation of the deferred returned by wrap() and the
+ entry expiring before the wrapped function returns.
+
+ The wrapped function should be cancelled.
+ """
+ cache = self.with_cache("cancel_expire_cache", ms=300)
+
+ expected_result = "howdy"
+
+ # Wrap the function so that we can keep track of when it completes or
+ # errors.
+ completed = False
+ cancelled = False
+
+ @wraps(self.delayed_return)
+ async def wrapped(o: str) -> str:
+ nonlocal completed, cancelled
+
+ try:
+ return await self.delayed_return(o)
+ except defer.CancelledError:
+ cancelled = True
+ raise
+ finally:
+ completed = True
+
+ wrap_d = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+
+ # cancel the deferred before it has a chance to return
+ wrap_d.cancel()
+
+ # The cancel should be ignored for now, and the inner function should
+ # still be running.
+ self.assertNoResult(wrap_d)
+ self.assertFalse(completed, "wrapped function should not have completed yet")
+
+ # Advance the clock until the cache entry should have expired, but not
+ # long enough for the inner function to have returned.
+ self.reactor.advance(0.7)
+
+ # The deferred we're waiting on should now return a cancelled error.
+ self.assertFailure(wrap_d, defer.CancelledError)
+ self.assertTrue(completed, "wrapped function should have completed")
+ self.assertTrue(cancelled, "wrapped function should have been cancelled")
+
+ def test_cache_cancel_first_wait_other_observers(self) -> None:
+ """Test that cancellation of the deferred returned by wrap() does not
+ cause a cancellation error to be raised if there are other observers
+ still waiting on the result.
+ """
+ cache = self.with_cache("cancel_other_cache", ms=300)
+
+ expected_result = "howdy"
+
+ # Wrap the function so that we can keep track of when it completes or
+ # errors.
+ completed = False
+ cancelled = False
+
+ @wraps(self.delayed_return)
+ async def wrapped(o: str) -> str:
+ nonlocal completed, cancelled
+
+ try:
+ return await self.delayed_return(o)
+ except defer.CancelledError:
+ cancelled = True
+ raise
+ finally:
+ completed = True
+
+ wrap_d1 = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+ wrap_d2 = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+
+ # cancel the first deferred before it has a chance to return
+ wrap_d1.cancel()
+
+ # The cancel should be ignored for now, and the inner function should
+ # still be running.
+ self.assertNoResult(wrap_d1)
+ self.assertNoResult(wrap_d2)
+ self.assertFalse(completed, "wrapped function should not have completed yet")
+
+ # Advance the clock until the cache entry should have expired, but not
+ # long enough for the inner function to have returned.
+ self.reactor.advance(0.7)
+
+ # Neither deferred should have returned yet, since the inner function
+ # should still be running.
+ self.assertNoResult(wrap_d1)
+ self.assertNoResult(wrap_d2)
+ self.assertFalse(completed, "wrapped function should not have completed yet")
+
+ # Now advance the clock until the inner function should have returned.
+ self.reactor.advance(2.5)
+
+ # The wrapped function should have completed without cancellation.
+ self.assertTrue(completed, "wrapped function should have completed")
+ self.assertFalse(cancelled, "wrapped function should not have been cancelled")
+
+ # The first deferred we're waiting on should now return a cancelled error.
+ self.assertFailure(wrap_d1, defer.CancelledError)
+
+ # The second deferred should return the result.
+ self.assertEqual(expected_result, self.successResultOf(wrap_d2))
+
+ def test_cache_add_and_cancel(self) -> None:
+ """Test that waiting on the cache and cancelling repeatedly keeps the
+ cache entry alive.
+ """
+ cache = self.with_cache("cancel_add_cache", ms=300)
+
+ expected_result = "howdy"
+
+ # Wrap the function so that we can keep track of when it completes or
+ # errors.
+ completed = False
+ cancelled = False
+
+ @wraps(self.delayed_return)
+ async def wrapped(o: str) -> str:
+ nonlocal completed, cancelled
+
+ try:
+ return await self.delayed_return(o)
+ except defer.CancelledError:
+ cancelled = True
+ raise
+ finally:
+ completed = True
+
+ # Repeatedly await for the result and cancel it, which should keep the
+ # cache entry alive even though the total time exceeds the cache
+ # timeout.
+ deferreds = []
+ for _ in range(8):
+ # Await the deferred.
+ wrap_d = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+
+ # cancel the deferred before it has a chance to return
+ self.reactor.advance(0.05)
+ wrap_d.cancel()
+ deferreds.append(wrap_d)
+
+ # The cancel should not cause the inner function to be cancelled
+ # yet.
+ self.assertFalse(
+ completed, "wrapped function should not have completed yet"
+ )
+ self.assertFalse(
+ cancelled, "wrapped function should not have been cancelled yet"
+ )
+
+ # Advance the clock until the cache entry should have expired, but not
+ # long enough for the inner function to have returned.
+ self.reactor.advance(0.05)
+
+ # Now advance the clock until the inner function should have returned.
+ self.reactor.advance(0.2)
+
+ # All the deferreds we're waiting on should now return a cancelled error.
+ for wrap_d in deferreds:
+ self.assertFailure(wrap_d, defer.CancelledError)
+
+ # The wrapped function should have completed without cancellation.
+ self.assertTrue(completed, "wrapped function should have completed")
+ self.assertFalse(cancelled, "wrapped function should not have been cancelled")
+
+ # Querying the cache should return the completed result
+ wrap_d = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+ self.assertEqual(expected_result, self.successResultOf(wrap_d))
+
+ def test_cache_cancel_non_cancellable(self) -> None:
+ """Test that cancellation of the deferred returned by wrap() on a
+ non-cancellable entry does not cause a cancellation error to be raised
+ when it's cancelled and the wrapped function continues execution.
+ """
+ cache = self.with_cache("cancel_non_cancellable_cache", ms=300)
+
+ expected_result = "howdy"
+
+ # Wrap the function so that we can keep track of when it completes or
+ # errors.
+ completed = False
+ cancelled = False
+
+ async def wrapped(o: str) -> str:
+ nonlocal completed, cancelled
+
+ try:
+ return await self.delayed_return(o)
+ except defer.CancelledError:
+ cancelled = True
+ raise
+ finally:
+ completed = True
+
+ wrap_d = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+
+ # cancel the deferred before it has a chance to return
+ wrap_d.cancel()
+
+ # The cancel should be ignored for now, and the inner function should
+ # still be running.
+ self.assertNoResult(wrap_d)
+ self.assertFalse(completed, "wrapped function should not have completed yet")
+
+ # Advance the clock until the inner function should have returned, but
+ # not long enough for the cache entry to have expired.
+ self.reactor.advance(2)
+
+ # The deferred we're waiting on should be cancelled, but a new call to
+ # the cache should return the result.
+ self.assertFailure(wrap_d, defer.CancelledError)
+ wrap_d2 = defer.ensureDeferred(cache.wrap(0, wrapped, expected_result))
+ self.assertEqual(expected_result, self.successResultOf(wrap_d2))
+
+ def test_cache_cancel_then_error(self) -> None:
+ """Test that cancellation of the deferred returned by wrap() that then
+ subsequently errors is correctly propagated to a second caller.
+ """
+
+ cache = self.with_cache("cancel_then_error_cache", ms=3000)
+
+ expected_error = Exception("oh no")
+
+ # Wrap the function so that we can keep track of when it completes or
+ # errors.
+ completed = False
+ cancelled = False
+
+ @wraps(self.delayed_return)
+ async def wrapped(o: str) -> str:
+ nonlocal completed, cancelled
+
+ try:
+ await self.delayed_return(o)
+ raise expected_error
+ except defer.CancelledError:
+ cancelled = True
+ raise
+ finally:
+ completed = True
+
+ wrap_d1 = defer.ensureDeferred(cache.wrap(0, wrapped, "ignored"))
+ wrap_d2 = defer.ensureDeferred(cache.wrap(0, wrapped, "ignored"))
+
+ # cancel the first deferred before it has a chance to return
+ wrap_d1.cancel()
+
+ # The cancel should be ignored for now, and the inner function should
+ # still be running.
+ self.assertNoResult(wrap_d1)
+ self.assertNoResult(wrap_d2)
+ self.assertFalse(completed, "wrapped function should not have completed yet")
+
+ # Advance the clock until the inner function should have returned.
+ self.reactor.advance(2)
+
+ # The wrapped function should have completed with an error without cancellation.
+ self.assertTrue(completed, "wrapped function should have completed")
+ self.assertFalse(cancelled, "wrapped function should not have been cancelled")
+
+ # The first deferred we're waiting on should now return a cancelled error.
+ self.assertFailure(wrap_d1, defer.CancelledError)
+
+ # The second deferred should return the error.
+ self.assertFailure(wrap_d2, Exception)
diff --git a/tests/util/test_async_helpers.py b/tests/util/test_async_helpers.py
index a6b7ddf485e..e4b1bb2b235 100644
--- a/tests/util/test_async_helpers.py
+++ b/tests/util/test_async_helpers.py
@@ -120,7 +120,7 @@ def check_failure(res: Failure, idx: int) -> None:
assert results[1] is not None
self.assertEqual(str(results[1].value), "gah!", "observer 2 errback result")
- def test_cancellation(self) -> None:
+ def test_cancellation_observer(self) -> None:
"""Test that cancelling an observer does not affect other observers."""
origin_d: "Deferred[int]" = Deferred()
observable = ObservableDeferred(origin_d, consumeErrors=True)
@@ -138,6 +138,10 @@ def test_cancellation(self) -> None:
self.assertFalse(observer1.called)
self.failureResultOf(observer2, CancelledError)
self.assertFalse(observer3.called)
+ # check that we remove the cancelled observer from the list of observers
+ # as a clean up.
+ self.assertEqual(len(observable.observers()), 2)
+ self.assertNotIn(observer2, observable.observers())
# other observers resolve as normal
origin_d.callback(123)
@@ -148,6 +152,22 @@ def test_cancellation(self) -> None:
observer4 = observable.observe()
self.assertEqual(observer4.result, 123, "observer 4 callback result")
+ def test_cancellation_observee(self) -> None:
+ """Test that cancelling the original deferred cancels all observers."""
+ origin_d: "Deferred[int]" = Deferred()
+ observable = ObservableDeferred(origin_d, consumeErrors=True)
+
+ observer1 = observable.observe()
+ observer2 = observable.observe()
+
+ self.assertFalse(observer1.called)
+ self.assertFalse(observer2.called)
+
+ # cancel the original deferred
+ origin_d.cancel()
+ self.failureResultOf(observer1, CancelledError)
+ self.failureResultOf(observer2, CancelledError)
+
class TimeoutDeferredTest(TestCase):
def setUp(self) -> None:
diff --git a/tests/util/test_wheel_timer.py b/tests/util/test_wheel_timer.py
index 6fa575a18e4..3dd9a9891f3 100644
--- a/tests/util/test_wheel_timer.py
+++ b/tests/util/test_wheel_timer.py
@@ -19,6 +19,7 @@
#
#
+from synapse.util.duration import Duration
from synapse.util.wheel_timer import WheelTimer
from .. import unittest
@@ -26,7 +27,7 @@
class WheelTimerTestCase(unittest.TestCase):
def test_single_insert_fetch(self) -> None:
- wheel: WheelTimer[object] = WheelTimer(bucket_size=5)
+ wheel: WheelTimer[object] = WheelTimer(bucket_size=Duration(milliseconds=5))
wheel.insert(100, "1", 150)
@@ -39,7 +40,7 @@ def test_single_insert_fetch(self) -> None:
self.assertListEqual(wheel.fetch(170), [])
def test_multi_insert(self) -> None:
- wheel: WheelTimer[object] = WheelTimer(bucket_size=5)
+ wheel: WheelTimer[object] = WheelTimer(bucket_size=Duration(milliseconds=5))
wheel.insert(100, "1", 150)
wheel.insert(105, "2", 130)
@@ -54,13 +55,13 @@ def test_multi_insert(self) -> None:
self.assertListEqual(wheel.fetch(210), [])
def test_insert_past(self) -> None:
- wheel: WheelTimer[object] = WheelTimer(bucket_size=5)
+ wheel: WheelTimer[object] = WheelTimer(bucket_size=Duration(milliseconds=5))
wheel.insert(100, "1", 50)
self.assertListEqual(wheel.fetch(120), ["1"])
def test_insert_past_multi(self) -> None:
- wheel: WheelTimer[object] = WheelTimer(bucket_size=5)
+ wheel: WheelTimer[object] = WheelTimer(bucket_size=Duration(milliseconds=5))
wheel.insert(100, "1", 150)
wheel.insert(100, "2", 140)
@@ -72,7 +73,7 @@ def test_insert_past_multi(self) -> None:
self.assertListEqual(wheel.fetch(240), [])
def test_multi_insert_then_past(self) -> None:
- wheel: WheelTimer[object] = WheelTimer(bucket_size=5)
+ wheel: WheelTimer[object] = WheelTimer(bucket_size=Duration(milliseconds=5))
wheel.insert(100, "1", 150)
wheel.insert(100, "2", 160)
From 9de28df7a25b1f0593b70416e170b54971fefab0 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 15:32:19 +0100
Subject: [PATCH 040/121] Bump docker/login-action from 3.6.0 to 3.7.0 in the
minor-and-patches group (#19493)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the minor-and-patches group with 1 update:
[docker/login-action](https://github.com/docker/login-action).
Updates `docker/login-action` from 3.6.0 to 3.7.0
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/docker.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ca20e9652db..6fb6b81deb9 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -79,7 +79,7 @@ jobs:
services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to Element OCI Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
@@ -176,7 +176,7 @@ jobs:
services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to Element OCI Registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
From b9ea2285b3cbc1e49be0d45cc46342fa6e167b28 Mon Sep 17 00:00:00 2001
From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Date: Fri, 27 Feb 2026 14:47:07 +0000
Subject: [PATCH 041/121] Add stable support for MSC4380 invite blocking.
(#19431)
MSC4380 has now completed FCP, so we can add stable support for it.
Co-authored-by: Quentin Gliech
---
changelog.d/19431.feature | 1 +
synapse/api/constants.py | 4 +-
synapse/api/errors.py | 2 +-
synapse/config/experimental.py | 3 --
synapse/rest/client/versions.py | 2 +-
.../storage/databases/main/account_data.py | 16 +++----
tests/handlers/test_room_member.py | 43 ++++---------------
7 files changed, 19 insertions(+), 52 deletions(-)
create mode 100644 changelog.d/19431.feature
diff --git a/changelog.d/19431.feature b/changelog.d/19431.feature
new file mode 100644
index 00000000000..0076e5221c6
--- /dev/null
+++ b/changelog.d/19431.feature
@@ -0,0 +1 @@
+Add stable support for [MSC4380](https://github.com/matrix-org/matrix-spec-proposals/pull/4380) invite blocking.
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index b8ef5dac50f..21139a3867a 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -325,9 +325,7 @@ class AccountDataTypes:
"org.matrix.msc4155.invite_permission_config"
)
# MSC4380: Invite blocking
- MSC4380_INVITE_PERMISSION_CONFIG: Final = (
- "org.matrix.msc4380.invite_permission_config"
- )
+ INVITE_PERMISSION_CONFIG: Final = "m.invite_permission_config"
# Synapse-specific behaviour. See "Client-Server API Extensions" documentation
# in Admin API for more information.
SYNAPSE_ADMIN_CLIENT_CONFIG: Final = "io.element.synapse.admin_client_config"
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index c299ca84d94..0c35b4a7ba5 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -138,7 +138,7 @@ class Codes(str, Enum):
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
# Part of MSC4155/MSC4380
- INVITE_BLOCKED = "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED"
+ INVITE_BLOCKED = "M_INVITE_BLOCKED"
# Part of MSC4190
APPSERVICE_LOGIN_UNSUPPORTED = "IO.ELEMENT.MSC4190.M_APPSERVICE_LOGIN_UNSUPPORTED"
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 05091ca6eb6..76e8de35fb8 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -606,6 +606,3 @@ def read_config(
# Note that sticky events persisted before this feature is enabled will not be
# considered sticky by the local homeserver.
self.msc4354_enabled: bool = experimental.get("msc4354_enabled", False)
-
- # MSC4380: Invite blocking
- self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index e443629faba..23f5ffeedb3 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -187,7 +187,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# MSC4354: Sticky events
"org.matrix.msc4354": self.config.experimental.msc4354_enabled,
# MSC4380: Invite blocking
- "org.matrix.msc4380": self.config.experimental.msc4380_enabled,
+ "org.matrix.msc4380.stable": True,
},
},
)
diff --git a/synapse/storage/databases/main/account_data.py b/synapse/storage/databases/main/account_data.py
index 71182cdab2b..538280137b0 100644
--- a/synapse/storage/databases/main/account_data.py
+++ b/synapse/storage/databases/main/account_data.py
@@ -109,7 +109,6 @@ def __init__(
)
self._msc4155_enabled = hs.config.experimental.msc4155_enabled
- self._msc4380_enabled = hs.config.experimental.msc4380_enabled
def get_max_account_data_stream_id(self) -> int:
"""Get the current max stream ID for account data stream
@@ -573,14 +572,13 @@ async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
Args:
user_id: The user whose invite configuration should be returned.
"""
- if self._msc4380_enabled:
- data = await self.get_global_account_data_by_type_for_user(
- user_id, AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG
- )
- # If the user has an MSC4380-style config setting, prioritise that
- # above an MSC4155 one
- if data is not None:
- return MSC4380InviteRulesConfig.from_account_data(data)
+ data = await self.get_global_account_data_by_type_for_user(
+ user_id, AccountDataTypes.INVITE_PERMISSION_CONFIG
+ )
+ # If the user has an MSC4380-style config setting, prioritise that
+ # above an MSC4155 one
+ if data is not None:
+ return MSC4380InviteRulesConfig.from_account_data(data)
if self._msc4155_enabled:
data = await self.get_global_account_data_by_type_for_user(
diff --git a/tests/handlers/test_room_member.py b/tests/handlers/test_room_member.py
index d8d7caaf1bd..3890abdbc83 100644
--- a/tests/handlers/test_room_member.py
+++ b/tests/handlers/test_room_member.py
@@ -503,7 +503,7 @@ def test_misc4155_block_invite_local(self) -> None:
SynapseError,
).value
self.assertEqual(f.code, 403)
- self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
+ self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
@override_config({"experimental_features": {"msc4155_enabled": False}})
def test_msc4155_disabled_allow_invite_local(self) -> None:
@@ -573,7 +573,7 @@ def test_msc4155_block_invite_remote(self) -> None:
SynapseError,
).value
self.assertEqual(f.code, 403)
- self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
+ self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
@override_config({"experimental_features": {"msc4155_enabled": True}})
def test_msc4155_block_invite_remote_server(self) -> None:
@@ -619,7 +619,7 @@ def test_msc4155_block_invite_remote_server(self) -> None:
SynapseError,
).value
self.assertEqual(f.code, 403)
- self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
+ self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
class TestMSC4380InviteBlocking(FederatingHomeserverTestCase):
@@ -642,7 +642,6 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.bob = self.register_user("bob", "pass")
self.bob_token = self.login("bob", "pass")
- @override_config({"experimental_features": {"msc4380_enabled": True}})
def test_misc4380_block_invite_local(self) -> None:
"""Test that MSC4380 will block a user from being invited to a room"""
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
@@ -650,7 +649,7 @@ def test_misc4380_block_invite_local(self) -> None:
self.get_success(
self.store.add_account_data_for_user(
self.bob,
- AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
+ AccountDataTypes.INVITE_PERMISSION_CONFIG,
{
"default_action": "block",
},
@@ -667,9 +666,8 @@ def test_misc4380_block_invite_local(self) -> None:
SynapseError,
).value
self.assertEqual(f.code, 403)
- self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
+ self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
- @override_config({"experimental_features": {"msc4380_enabled": True}})
def test_misc4380_non_string_setting(self) -> None:
"""Test that `default_action` being set to something non-stringy is the same as "accept"."""
room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
@@ -677,7 +675,7 @@ def test_misc4380_non_string_setting(self) -> None:
self.get_success(
self.store.add_account_data_for_user(
self.bob,
- AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
+ AccountDataTypes.INVITE_PERMISSION_CONFIG,
{
"default_action": 1,
},
@@ -693,31 +691,6 @@ def test_misc4380_non_string_setting(self) -> None:
)
)
- @override_config({"experimental_features": {"msc4380_enabled": False}})
- def test_msc4380_disabled_allow_invite_local(self) -> None:
- """Test that, when MSC4380 is not enabled, invites are accepted as normal"""
- room_id = self.helper.create_room_as(self.alice, tok=self.alice_token)
-
- self.get_success(
- self.store.add_account_data_for_user(
- self.bob,
- AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
- {
- "default_action": "block",
- },
- )
- )
-
- self.get_success(
- self.handler.update_membership(
- requester=create_requester(self.alice),
- target=UserID.from_string(self.bob),
- room_id=room_id,
- action=Membership.INVITE,
- ),
- )
-
- @override_config({"experimental_features": {"msc4380_enabled": True}})
def test_msc4380_block_invite_remote(self) -> None:
"""Test that MSC4380 will block a user from being invited to a room by a remote user."""
# A remote user who sends the invite
@@ -727,7 +700,7 @@ def test_msc4380_block_invite_remote(self) -> None:
self.get_success(
self.store.add_account_data_for_user(
self.bob,
- AccountDataTypes.MSC4380_INVITE_PERMISSION_CONFIG,
+ AccountDataTypes.INVITE_PERMISSION_CONFIG,
{"default_action": "block"},
)
)
@@ -761,4 +734,4 @@ def test_msc4380_block_invite_remote(self) -> None:
SynapseError,
).value
self.assertEqual(f.code, 403)
- self.assertEqual(f.errcode, "ORG.MATRIX.MSC4155.M_INVITE_BLOCKED")
+ self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
From 979566ed8fa25511a1d858229c4b8848353c3167 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Fri, 27 Feb 2026 16:25:26 -0600
Subject: [PATCH 042/121] Pre-allocate the buffer based on the expected
`Content-Length` with the Rust HTTP client (#19498)
Spawning from
[looking](https://matrix.to/#/!cnVVNLKqgUzNTOFQkz:matrix.org/$XOVFm5mjCzzmhUaGc202zGdSq8eWgjr00MJqNSfzHiA?via=element.io&via=matrix.org&via=one.ems.host)
at some traces and seeing the Synapse Rust HTTP client taking way longer
than what the Synapse Pro Event Cache claims it was able to respond in
(added some [better
tracing](https://github.com/element-hq/synapse-pro-modules/pull/38) for
that). I don't think this specific change will have a meaningful impact
but just something I saw (pre-optimization).
---
changelog.d/19498.misc | 1 +
rust/src/http_client.rs | 42 ++++++++++++++++++++++++++++++++++++++++-
2 files changed, 42 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/19498.misc
diff --git a/changelog.d/19498.misc b/changelog.d/19498.misc
new file mode 100644
index 00000000000..7d048c283d6
--- /dev/null
+++ b/changelog.d/19498.misc
@@ -0,0 +1 @@
+Pre-allocate the buffer based on the expected `Content-Length` with the Rust HTTP client.
diff --git a/rust/src/http_client.rs b/rust/src/http_client.rs
index 9bbdff8b454..dd37a10426b 100644
--- a/rust/src/http_client.rs
+++ b/rust/src/http_client.rs
@@ -16,6 +16,7 @@ use std::{collections::HashMap, future::Future, sync::OnceLock};
use anyhow::Context;
use futures::TryStreamExt;
+use headers::HeaderMapExt;
use once_cell::sync::OnceCell;
use pyo3::{create_exception, exceptions::PyException, prelude::*};
use reqwest::RequestBuilder;
@@ -235,8 +236,47 @@ impl HttpClient {
let status = response.status();
+ // Find the expected `Content-Length` so we can pre-allocate the buffer
+ // necessary to read the response. It's expected that not every request will
+ // have a `Content-Length` header.
+ //
+ // `response.content_length()` does exist but the "value does not directly
+ // represents the value of the `Content-Length` header, but rather the size
+ // of the response’s body"
+ // (https://docs.rs/reqwest/latest/reqwest/struct.Response.html#method.content_length)
+ // and we want to avoid reading the entire body at this point because we
+ // purposely stream it below until the `response_limit`.
+ let content_length = {
+ let content_length = response
+ .headers()
+ .typed_get::()
+ // We need a `usize` for the `Vec::with_capacity(...)` usage below
+ .and_then(|content_length| content_length.0.try_into().ok());
+
+ // Sanity check that the request isn't too large from the information
+ // they told us (may be inaccurate so we also check below as we actually
+ // read the bytes)
+ if let Some(content_length_bytes) = content_length {
+ if content_length_bytes > response_limit {
+ Err(anyhow::anyhow!(
+ "Response size (defined by `Content-Length`) too large"
+ ))?;
+ }
+ }
+
+ content_length
+ };
+
+ // Stream the response to avoid allocating a giant object on the server
+ // above our expected `response_limit`.
let mut stream = response.bytes_stream();
- let mut buffer = Vec::new();
+ // Pre-allocate the buffer based on the expected `Content-Length`
+ let mut buffer = Vec::with_capacity(
+ content_length
+ // Default to pre-allocating nothing when the request doesn't have a
+ // `Content-Length` header
+ .unwrap_or(0),
+ );
while let Some(chunk) = stream.try_next().await.context("reading body")? {
if buffer.len() + chunk.len() > response_limit {
Err(anyhow::anyhow!("Response size too large"))?;
From 0d3e42f21f55e83f0cca08fe4d5c6c63834ce9b5 Mon Sep 17 00:00:00 2001
From: Erik Johnston
Date: Mon, 2 Mar 2026 10:36:27 +0100
Subject: [PATCH 043/121] Yield to reactor in large loops (#19507)
When a worker gets very busy some of these loops can get large and end
up taking hundreds of ms to complete. To help keep the reactor tick
times reasonable we add a periodic yield into these loops.
These were found by doing a `py-spy` and speedscope.net (in time order)
to see where we were spending blocks of time
---
changelog.d/19507.misc | 1 +
.../storage/databases/main/events_worker.py | 27 ++++++++++++++++---
synapse/storage/databases/main/roommember.py | 2 +-
synapse/util/background_queue.py | 13 +++++++++
4 files changed, 39 insertions(+), 4 deletions(-)
create mode 100644 changelog.d/19507.misc
diff --git a/changelog.d/19507.misc b/changelog.d/19507.misc
new file mode 100644
index 00000000000..5a7ccff988c
--- /dev/null
+++ b/changelog.d/19507.misc
@@ -0,0 +1 @@
+Try and reduce reactor tick times when under heavy load.
diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py
index ae6ee50dc24..cb0764feb8c 100644
--- a/synapse/storage/databases/main/events_worker.py
+++ b/synapse/storage/databases/main/events_worker.py
@@ -132,6 +132,11 @@ def __init__(
EVENT_QUEUE_TIMEOUT_S = 0.1 # Timeout when waiting for requests for events
+# Number of iterations in a loop before we yield to the reactor to allow other
+# things to be processed, otherwise we can end up tight looping.
+ITERATIONS_BEFORE_YIELDING = 500
+
+
event_fetch_ongoing_gauge = Gauge(
"synapse_event_fetch_ongoing",
"The number of event fetchers that are running",
@@ -817,7 +822,7 @@ async def get_unredacted_events_from_cache_or_db(
# may be called repeatedly for the same event so at this point we cannot reach
# out to any external cache for performance reasons. The external cache is
# checked later on in the `get_missing_events_from_cache_or_db` function below.
- event_entry_map = self._get_events_from_local_cache(
+ event_entry_map = await self._get_events_from_local_cache(
event_ids,
)
@@ -1004,7 +1009,7 @@ async def _get_events_from_cache(
events: list of event_ids to fetch
update_metrics: Whether to update the cache hit ratio metrics
"""
- event_map = self._get_events_from_local_cache(
+ event_map = await self._get_events_from_local_cache(
events, update_metrics=update_metrics
)
@@ -1045,7 +1050,7 @@ async def _get_events_from_external_cache(
return event_map
- def _get_events_from_local_cache(
+ async def _get_events_from_local_cache(
self, events: Iterable[str], update_metrics: bool = True
) -> dict[str, EventCacheEntry]:
"""Fetch events from the local, in memory, caches.
@@ -1058,7 +1063,15 @@ def _get_events_from_local_cache(
"""
event_map = {}
+ i = 0
for event_id in events:
+ i += 1
+
+ # Yield to the reactor to allow other things to be processed,
+ # otherwise we can end up tight looping.
+ if i % ITERATIONS_BEFORE_YIELDING == 0:
+ await self.clock.sleep(Duration(seconds=0))
+
# First check if it's in the event cache
ret = self._get_event_cache.get_local(
(event_id,), None, update_metrics=update_metrics
@@ -1375,7 +1388,15 @@ async def _fetch_event_ids_and_get_outstanding_redactions(
# build a map from event_id to EventBase
event_map: dict[str, EventBase] = {}
+ i = 0
for event_id, row in fetched_events.items():
+ i += 1
+
+ # Yield to the reactor to allow other things to be processed,
+ # otherwise we can end up tight looping.
+ if i % ITERATIONS_BEFORE_YIELDING == 0:
+ await self.clock.sleep(Duration(seconds=0))
+
assert row.event_id == event_id
rejected_reason = row.rejected_reason
diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py
index 7c06080f10a..736f3e4c781 100644
--- a/synapse/storage/databases/main/roommember.py
+++ b/synapse/storage/databases/main/roommember.py
@@ -1032,7 +1032,7 @@ async def get_joined_user_ids_from_state(
# We don't update the event cache hit ratio as it completely throws off
# the hit ratio counts. After all, we don't populate the cache if we
# miss it here
- event_map = self._get_events_from_local_cache(
+ event_map = await self._get_events_from_local_cache(
member_event_ids, update_metrics=False
)
diff --git a/synapse/util/background_queue.py b/synapse/util/background_queue.py
index dfea7247f40..a86b0d0730e 100644
--- a/synapse/util/background_queue.py
+++ b/synapse/util/background_queue.py
@@ -37,6 +37,11 @@
T = TypeVar("T")
+# Number of iterations in a loop before we yield to the reactor to allow other
+# things to be processed, otherwise we can end up tight looping.
+ITERATIONS_BEFORE_YIELDING = 500
+
+
class BackgroundQueue(Generic[T]):
"""A single-producer single-consumer async queue processing items in the
background.
@@ -65,6 +70,7 @@ def __init__(
timeout_ms: int = 1000,
) -> None:
self._hs = hs
+ self._clock = hs.get_clock()
self._name = name
self._callback = callback
self._timeout_ms = Duration(milliseconds=timeout_ms)
@@ -107,7 +113,14 @@ async def _process_queue(self) -> None:
# single threaded nature, but let's be a bit defensive anyway.)
self._wakeup_event.clear()
+ iterations = 0
while self._queue:
+ iterations += 1
+ if iterations % ITERATIONS_BEFORE_YIELDING == 0:
+ # Yield to the reactor to allow other things to be processed,
+ # otherwise we can end up tight looping.
+ await self._clock.sleep(Duration(seconds=0))
+
item = self._queue.popleft()
try:
await self._callback(item)
From 825f3087bff83fc43164f14dd84b615cdb442c5c Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Mon, 2 Mar 2026 18:15:33 +0000
Subject: [PATCH 044/121] Replace deprecated collection import locations with
current locations. (#19515)
Use non-deprecated imports for collections
Other than being deprecated, these legacy imports also don't seem to be
compatible with [Ty](https://github.com/astral-sh/ty)
---------
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19515.misc | 1 +
synapse/event_auth.py | 3 ++-
synapse/handlers/sliding_sync/extensions.py | 2 +-
synapse/storage/controllers/stats.py | 3 ++-
synapse/storage/databases/main/stats.py | 2 +-
synapse/types/handlers/sliding_sync.py | 5 ++---
synapse/util/async_helpers.py | 4 ++--
synapse/util/caches/__init__.py | 4 ++--
8 files changed, 13 insertions(+), 11 deletions(-)
create mode 100644 changelog.d/19515.misc
diff --git a/changelog.d/19515.misc b/changelog.d/19515.misc
new file mode 100644
index 00000000000..d33af364066
--- /dev/null
+++ b/changelog.d/19515.misc
@@ -0,0 +1 @@
+Replace deprecated collection import locations with current locations.
\ No newline at end of file
diff --git a/synapse/event_auth.py b/synapse/event_auth.py
index 4b354dd6fbd..7098843742e 100644
--- a/synapse/event_auth.py
+++ b/synapse/event_auth.py
@@ -20,12 +20,13 @@
#
#
+import collections
import collections.abc
import logging
import typing
+from collections import ChainMap
from typing import (
Any,
- ChainMap,
Iterable,
Mapping,
MutableMapping,
diff --git a/synapse/handlers/sliding_sync/extensions.py b/synapse/handlers/sliding_sync/extensions.py
index d076bec51a9..9b7a01df142 100644
--- a/synapse/handlers/sliding_sync/extensions.py
+++ b/synapse/handlers/sliding_sync/extensions.py
@@ -14,10 +14,10 @@
import itertools
import logging
+from collections import ChainMap
from typing import (
TYPE_CHECKING,
AbstractSet,
- ChainMap,
Mapping,
MutableMapping,
Sequence,
diff --git a/synapse/storage/controllers/stats.py b/synapse/storage/controllers/stats.py
index 18e27e08781..5378797f046 100644
--- a/synapse/storage/controllers/stats.py
+++ b/synapse/storage/controllers/stats.py
@@ -20,7 +20,8 @@
#
import logging
-from typing import TYPE_CHECKING, Collection, Counter
+from collections import Counter
+from typing import TYPE_CHECKING, Collection
from synapse.api.errors import SynapseError
from synapse.storage.database import LoggingTransaction
diff --git a/synapse/storage/databases/main/stats.py b/synapse/storage/databases/main/stats.py
index 6568e2aa08c..687cabe69e3 100644
--- a/synapse/storage/databases/main/stats.py
+++ b/synapse/storage/databases/main/stats.py
@@ -20,12 +20,12 @@
#
import logging
+from collections import Counter
from enum import Enum
from itertools import chain
from typing import (
TYPE_CHECKING,
Any,
- Counter,
Iterable,
cast,
)
diff --git a/synapse/types/handlers/sliding_sync.py b/synapse/types/handlers/sliding_sync.py
index dcb125c494a..694b3e1645e 100644
--- a/synapse/types/handlers/sliding_sync.py
+++ b/synapse/types/handlers/sliding_sync.py
@@ -13,7 +13,6 @@
#
import logging
-import typing
from collections import ChainMap
from enum import Enum
from typing import (
@@ -793,7 +792,7 @@ class MutableRoomStatusMap(RoomStatusMap[T]):
# We use a ChainMap here so that we can easily track what has been updated
# and what hasn't. Note that when we persist the per connection state this
# will get flattened to a normal dict (via calling `.copy()`)
- _statuses: typing.ChainMap[str, HaveSentRoom[T]]
+ _statuses: ChainMap[str, HaveSentRoom[T]]
def __init__(
self,
@@ -973,7 +972,7 @@ class MutablePerConnectionState(PerConnectionState):
receipts: MutableRoomStatusMap[MultiWriterStreamToken]
account_data: MutableRoomStatusMap[int]
- room_configs: typing.ChainMap[str, RoomSyncConfig]
+ room_configs: ChainMap[str, RoomSyncConfig]
# A map from room ID to the lazily-loaded memberships needed for the
# request in that room.
diff --git a/synapse/util/async_helpers.py b/synapse/util/async_helpers.py
index 53fb24ec5b8..1c3cd48a9c0 100644
--- a/synapse/util/async_helpers.py
+++ b/synapse/util/async_helpers.py
@@ -25,7 +25,7 @@
import inspect
import itertools
import logging
-import typing
+from collections import OrderedDict
from contextlib import asynccontextmanager
from typing import (
Any,
@@ -572,7 +572,7 @@ class _LinearizerEntry:
# The number of things executing.
count: int
# Deferreds for the things blocked from executing.
- deferreds: typing.OrderedDict["defer.Deferred[None]", Literal[1]]
+ deferreds: OrderedDict["defer.Deferred[None]", Literal[1]]
class Linearizer:
diff --git a/synapse/util/caches/__init__.py b/synapse/util/caches/__init__.py
index a65ab7f57d8..bd52c0c0ed0 100644
--- a/synapse/util/caches/__init__.py
+++ b/synapse/util/caches/__init__.py
@@ -21,7 +21,7 @@
#
import collections
import logging
-import typing
+from collections import Counter
from enum import Enum, auto
from sys import intern
from typing import Any, Callable, Sized, TypeVar
@@ -134,7 +134,7 @@ class CacheMetric:
hits: int = 0
misses: int = 0
- eviction_size_by_reason: typing.Counter[EvictionReason] = attr.ib(
+ eviction_size_by_reason: Counter[EvictionReason] = attr.ib(
factory=collections.Counter
)
memory_usage: int | None = None
From 2deeef41187d94c34a229d1d6b1456c9c05026ae Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 3 Mar 2026 11:24:17 +0100
Subject: [PATCH 045/121] Bump futures from 0.3.31 to 0.3.32 in the patches
group (#19513)
Bumps the patches group with 1 update:
[futures](https://github.com/rust-lang/futures-rs).
Updates `futures` from 0.3.31 to 0.3.32
Release notes
We coordinated with them to release this version to patch the issue.
Unfortunately the maintainers missed these issues during code review and
we did not have enough fuzzing coverage -- we regret the oversight and
have added an additional fuzzing target.
Organizations that want to participate in coordinated disclosure can
contact us privately to discuss terms.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/element-hq/synapse/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 340114f8018..eed90d64714 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -910,9 +910,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.12"
+version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.3",
From 4c475dcd7a566367708c59642d17cf7fdd3c0507 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Thu, 12 Mar 2026 18:11:09 +0100
Subject: [PATCH 065/121] Allow the caching of the /versions and /auth_metadata
endpoints (#19530)
Can be reviewed commit by commit.
This sets caching headers on the /versions and /auth_metadata endpoints
to:
- allow clients to cache the response for up to 10 minutes
(`max-age=600`)
- allow proxies to cache the response for up to an hour
(`s-maxage=3600`)
- make proxies serve stale response for up to an hour (`s-maxage=3600`)
but make them refresh their response after 10 minutes
(`stale-while-revalidate=600`) so that we always have a snappy response
to client, but also have fresh responses most of the time
- only cache the response for unauthenticated requests on /versions
(`Vary: Authorization`)
I'm not too worried about the 1h TTL on the proxy side, as with the
`stale-while-revalidate` directive, one just needs to do two requests
after 10 minutes to get a fresh response from the cache.
The reason we want this, is that clients usually load this right away,
leading to a lot of traffic from people just loading the Element Web
login screen with the default config. This is currently routed to
`client_readers` on matrix.org (and ESS) which can be overwhelmed for
other reasons, leading to slow response times on those endpoints (3s+).
Overwhelmed workers shouldn't prevent people from logging in, and
shouldn't result in a long loading spinner in clients. This PR allows
caching proxies (like Cloudflare) to publicly cache the unauthenticated
response of those two endpoints and make it load quicker, reducing
server load as well.
---
changelog.d/19530.misc | 1 +
synapse/http/server.py | 26 +++++++++++++++++--
synapse/rest/client/auth_metadata.py | 38 ++++++++++++++++++++++++++++
synapse/rest/client/versions.py | 20 +++++++++++++++
4 files changed, 83 insertions(+), 2 deletions(-)
create mode 100644 changelog.d/19530.misc
diff --git a/changelog.d/19530.misc b/changelog.d/19530.misc
new file mode 100644
index 00000000000..9e5bc0fe047
--- /dev/null
+++ b/changelog.d/19530.misc
@@ -0,0 +1 @@
+Allow caching of the `/versions` and `/auth_metadata` public endpoints.
diff --git a/synapse/http/server.py b/synapse/http/server.py
index 226cb008317..2c235e04f4e 100644
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -861,7 +861,18 @@ def respond_with_json(
encoder = _encode_json_bytes
request.setHeader(b"Content-Type", b"application/json")
- request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
+ # Insert a default Cache-Control header if the servlet hasn't already set one. The
+ # default directive tells both the client and any intermediary cache to not cache
+ # the response, which is a sensible default to have on most API endpoints.
+ # The absence `Cache-Control` header would mean that it's up to the clients and
+ # caching proxies mood to cache things if they want. This can be dangerous, which is
+ # why we explicitly set a "don't cache by default" policy.
+ # In practice, `no-store` should be enough, but having all three directives is more
+ # conservative in case we encounter weird, non-spec compliant caches.
+ # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives
+ # for more details.
+ if not request.responseHeaders.hasHeader(b"Cache-Control"):
+ request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
if send_cors:
set_cors_headers(request)
@@ -901,7 +912,18 @@ def respond_with_json_bytes(
request.setHeader(b"Content-Type", b"application/json")
request.setHeader(b"Content-Length", b"%d" % (len(json_bytes),))
- request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
+ # Insert a default Cache-Control header if the servlet hasn't already set one. The
+ # default directive tells both the client and any intermediary cache to not cache
+ # the response, which is a sensible default to have on most API endpoints.
+ # The absence `Cache-Control` header would mean that it's up to the clients and
+ # caching proxies mood to cache things if they want. This can be dangerous, which is
+ # why we explicitly set a "don't cache by default" policy.
+ # In practice, `no-store` should be enough, but having all three directives is more
+ # conservative in case we encounter weird, non-spec compliant caches.
+ # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives
+ # for more details.
+ if not request.responseHeaders.hasHeader(b"Cache-Control"):
+ request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
if send_cors:
set_cors_headers(request)
diff --git a/synapse/rest/client/auth_metadata.py b/synapse/rest/client/auth_metadata.py
index 702f550906b..062b8ed13e3 100644
--- a/synapse/rest/client/auth_metadata.py
+++ b/synapse/rest/client/auth_metadata.py
@@ -49,6 +49,25 @@ def __init__(self, hs: "HomeServer"):
self._auth = hs.get_auth()
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
+ # This endpoint is unauthenticated and the response only depends on
+ # the metadata we get from Matrix Authentication Service. Internally,
+ # MasDelegatedAuth/MSC3861DelegatedAuth.issuer() are already caching the
+ # response in memory anyway. Ideally we would follow any Cache-Control directive
+ # given by MAS, but this is fine for now.
+ #
+ # - `public` means it can be cached both in the browser and in caching proxies
+ # - `max-age` controls how long we cache on the browser side. 10m is sane enough
+ # - `s-maxage` controls how long we cache on the proxy side. Since caching
+ # proxies usually have a way to purge caches, it is fine to cache there for
+ # longer (1h), and issue cache invalidations in case we need it
+ # - `stale-while-revalidate` allows caching proxies to serve stale content while
+ # revalidating in the background. This is useful for making this request always
+ # 'snappy' to end users whilst still keeping it fresh
+ request.setHeader(
+ b"Cache-Control",
+ b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
+ )
+
if self._config.mas.enabled:
assert isinstance(self._auth, MasDelegatedAuth)
return 200, {"issuer": await self._auth.issuer()}
@@ -94,6 +113,25 @@ def __init__(self, hs: "HomeServer"):
self._auth = hs.get_auth()
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
+ # This endpoint is unauthenticated and the response only depends on
+ # the metadata we get from Matrix Authentication Service. Internally,
+ # MasDelegatedAuth/MSC3861DelegatedAuth.issuer() are already caching the
+ # response in memory anyway. Ideally we would follow any Cache-Control directive
+ # given by MAS, but this is fine for now.
+ #
+ # - `public` means it can be cached both in the browser and in caching proxies
+ # - `max-age` controls how long we cache on the browser side. 10m is sane enough
+ # - `s-maxage` controls how long we cache on the proxy side. Since caching
+ # proxies usually have a way to purge caches, it is fine to cache there for
+ # longer (1h), and issue cache invalidations in case we need it
+ # - `stale-while-revalidate` allows caching proxies to serve stale content while
+ # revalidating in the background. This is useful for making this request always
+ # 'snappy' to end users whilst still keeping it fresh
+ request.setHeader(
+ b"Cache-Control",
+ b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
+ )
+
if self._config.mas.enabled:
assert isinstance(self._auth, MasDelegatedAuth)
return 200, await self._auth.auth_metadata()
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 23f5ffeedb3..f8d7a1a4d9d 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -81,6 +81,26 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
msc3575_enabled = await self.store.is_feature_enabled(
user_id, ExperimentalFeature.MSC3575
)
+ else:
+ # Allow caching of unauthenticated responses, as they only depend
+ # on server configuration which rarely changes.
+ #
+ # - `public` means it can be cached both in the browser and in caching proxies
+ # - `max-age` controls how long we cache on the browser side. 10m is sane enough
+ # - `s-maxage` controls how long we cache on the proxy side. Since caching
+ # proxies usually have a way to purge caches, it is fine to cache there for
+ # longer (1h), and issue cache invalidations in case we need it
+ # - `stale-while-revalidate` allows caching proxies to serve stale content while
+ # revalidating in the background. This is useful for making this request always
+ # 'snappy' to end users whilst still keeping it fresh
+ request.setHeader(
+ b"Cache-Control",
+ b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
+ )
+
+ # Tell caches to vary on the Authorization header, so that
+ # authenticated responses are not served from cache.
+ request.setHeader(b"Vary", b"Authorization")
return (
200,
From c0924fbbd8ba6c3d9c0984a05a3160494abc7fbd Mon Sep 17 00:00:00 2001
From: Andrew Ferrazzutti
Date: Mon, 16 Mar 2026 12:29:42 -0400
Subject: [PATCH 066/121] MSC4140: put delay_id in unsigned data for sender
(#19479)
Implements
https://github.com/matrix-org/matrix-spec-proposals/pull/4140/changes/49b200dcc11de286974925177b1e184cd905e6fa
---
changelog.d/19479.feature | 1 +
rust/src/events/internal_metadata.rs | 22 +++++
synapse/events/utils.py | 81 +++++++++--------
synapse/handlers/delayed_events.py | 2 +
synapse/handlers/message.py | 13 ++-
synapse/handlers/room_member.py | 10 +++
synapse/synapse_rust/events.pyi | 2 +
tests/rest/client/test_delayed_events.py | 108 ++++++++++++++++++++++-
8 files changed, 199 insertions(+), 40 deletions(-)
create mode 100644 changelog.d/19479.feature
diff --git a/changelog.d/19479.feature b/changelog.d/19479.feature
new file mode 100644
index 00000000000..3e7e8bd6ffb
--- /dev/null
+++ b/changelog.d/19479.feature
@@ -0,0 +1 @@
+[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): When persisting a delayed event to the timeline, include its `delay_id` in the event's `unsigned` section in `/sync` responses to the event sender.
diff --git a/rust/src/events/internal_metadata.rs b/rust/src/events/internal_metadata.rs
index fa40fdcfad8..595f9cf7eb7 100644
--- a/rust/src/events/internal_metadata.rs
+++ b/rust/src/events/internal_metadata.rs
@@ -57,6 +57,7 @@ enum EventInternalMetadataData {
PolicyServerSpammy(bool),
Redacted(bool),
TxnId(Box),
+ DelayId(Box),
TokenId(i64),
DeviceId(Box),
}
@@ -115,6 +116,10 @@ impl EventInternalMetadataData {
pyo3::intern!(py, "txn_id"),
o.into_pyobject(py).unwrap_infallible().into_any(),
),
+ EventInternalMetadataData::DelayId(o) => (
+ pyo3::intern!(py, "delay_id"),
+ o.into_pyobject(py).unwrap_infallible().into_any(),
+ ),
EventInternalMetadataData::TokenId(o) => (
pyo3::intern!(py, "token_id"),
o.into_pyobject(py).unwrap_infallible().into_any(),
@@ -179,6 +184,12 @@ impl EventInternalMetadataData {
.map(String::into_boxed_str)
.with_context(|| format!("'{key_str}' has invalid type"))?,
),
+ "delay_id" => EventInternalMetadataData::DelayId(
+ value
+ .extract()
+ .map(String::into_boxed_str)
+ .with_context(|| format!("'{key_str}' has invalid type"))?,
+ ),
"token_id" => EventInternalMetadataData::TokenId(
value
.extract()
@@ -472,6 +483,17 @@ impl EventInternalMetadata {
set_property!(self, TxnId, obj.into_boxed_str());
}
+ /// The delay ID, set only if the event was a delayed event.
+ #[getter]
+ fn get_delay_id(&self) -> PyResult<&str> {
+ let s = get_property!(self, DelayId)?;
+ Ok(s)
+ }
+ #[setter]
+ fn set_delay_id(&mut self, obj: String) {
+ set_property!(self, DelayId, obj.into_boxed_str());
+ }
+
/// The access token ID of the user who sent this event, if any.
#[getter]
fn get_token_id(&self) -> PyResult {
diff --git a/synapse/events/utils.py b/synapse/events/utils.py
index 1bf4d632c00..89eb2182afd 100644
--- a/synapse/events/utils.py
+++ b/synapse/events/utils.py
@@ -420,7 +420,7 @@ class SerializeEventConfig:
# Function to convert from federation format to client format
event_format: Callable[[JsonDict], JsonDict] = format_event_for_client_v1
# The entity that requested the event. This is used to determine whether to include
- # the transaction_id in the unsigned section of the event.
+ # the transaction_id and delay_id in the unsigned section of the event.
requester: Requester | None = None
# List of event fields to include. If empty, all fields will be returned.
only_event_fields: list[str] | None = None
@@ -483,44 +483,49 @@ def serialize_event(
config=config,
)
- # If we have a txn_id saved in the internal_metadata, we should include it in the
- # unsigned section of the event if it was sent by the same session as the one
- # requesting the event.
- txn_id: str | None = getattr(e.internal_metadata, "txn_id", None)
- if (
- txn_id is not None
- and config.requester is not None
- and config.requester.user.to_string() == e.sender
- ):
- # Some events do not have the device ID stored in the internal metadata,
- # this includes old events as well as those created by appservice, guests,
- # or with tokens minted with the admin API. For those events, fallback
- # to using the access token instead.
- event_device_id: str | None = getattr(e.internal_metadata, "device_id", None)
- if event_device_id is not None:
- if event_device_id == config.requester.device_id:
- d["unsigned"]["transaction_id"] = txn_id
-
- else:
- # Fallback behaviour: only include the transaction ID if the event
- # was sent from the same access token.
- #
- # For regular users, the access token ID can be used to determine this.
- # This includes access tokens minted with the admin API.
- #
- # For guests and appservice users, we can't check the access token ID
- # so assume it is the same session.
- event_token_id: int | None = getattr(e.internal_metadata, "token_id", None)
- if (
- (
- event_token_id is not None
- and config.requester.access_token_id is not None
- and event_token_id == config.requester.access_token_id
+ # If we have applicable fields saved in the internal_metadata, include them in the
+ # unsigned section of the event if the event was sent by the same session (or when
+ # appropriate, just the same sender) as the one requesting the event.
+ if config.requester is not None and config.requester.user.to_string() == e.sender:
+ txn_id: str | None = getattr(e.internal_metadata, "txn_id", None)
+ if txn_id is not None:
+ # Some events do not have the device ID stored in the internal metadata,
+ # this includes old events as well as those created by appservice, guests,
+ # or with tokens minted with the admin API. For those events, fallback
+ # to using the access token instead.
+ event_device_id: str | None = getattr(
+ e.internal_metadata, "device_id", None
+ )
+ if event_device_id is not None:
+ if event_device_id == config.requester.device_id:
+ d["unsigned"]["transaction_id"] = txn_id
+
+ else:
+ # Fallback behaviour: only include the transaction ID if the event
+ # was sent from the same access token.
+ #
+ # For regular users, the access token ID can be used to determine this.
+ # This includes access tokens minted with the admin API.
+ #
+ # For guests and appservice users, we can't check the access token ID
+ # so assume it is the same session.
+ event_token_id: int | None = getattr(
+ e.internal_metadata, "token_id", None
)
- or config.requester.is_guest
- or config.requester.app_service
- ):
- d["unsigned"]["transaction_id"] = txn_id
+ if (
+ (
+ event_token_id is not None
+ and config.requester.access_token_id is not None
+ and event_token_id == config.requester.access_token_id
+ )
+ or config.requester.is_guest
+ or config.requester.app_service
+ ):
+ d["unsigned"]["transaction_id"] = txn_id
+
+ delay_id: str | None = getattr(e.internal_metadata, "delay_id", None)
+ if delay_id is not None:
+ d["unsigned"]["org.matrix.msc4140.delay_id"] = delay_id
# invite_room_state and knock_room_state are a list of stripped room state events
# that are meant to provide metadata about a room to an invitee/knocker. They are
diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py
index 7e41716f1e2..4a9f646d4db 100644
--- a/synapse/handlers/delayed_events.py
+++ b/synapse/handlers/delayed_events.py
@@ -560,6 +560,7 @@ async def _send_event(
action=membership,
content=event.content,
origin_server_ts=event.origin_server_ts,
+ delay_id=event.delay_id,
)
else:
event_dict: JsonDict = {
@@ -585,6 +586,7 @@ async def _send_event(
requester,
event_dict,
txn_id=txn_id,
+ delay_id=event.delay_id,
)
event_id = sent_event.event_id
except ShadowBanError:
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 99ce120736d..eb016225159 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -585,6 +585,7 @@ async def create_event(
state_map: StateMap[str] | None = None,
for_batch: bool = False,
current_state_group: int | None = None,
+ delay_id: str | None = None,
) -> tuple[EventBase, UnpersistedEventContextBase]:
"""
Given a dict from a client, create a new event. If bool for_batch is true, will
@@ -600,7 +601,7 @@ async def create_event(
Args:
requester
event_dict: An entire event
- txn_id
+ txn_id: The transaction ID.
prev_event_ids:
the forward extremities to use as the prev_events for the
new event.
@@ -639,6 +640,8 @@ async def create_event(
current_state_group: the current state group, used only for creating events for
batch persisting
+ delay_id: The delay ID of this event, if it was a delayed event.
+
Raises:
ResourceLimitError if server is blocked to some resource being
exceeded
@@ -726,6 +729,9 @@ async def create_event(
if txn_id is not None:
builder.internal_metadata.txn_id = txn_id
+ if delay_id is not None:
+ builder.internal_metadata.delay_id = delay_id
+
builder.internal_metadata.outlier = outlier
event, unpersisted_context = await self.create_new_client_event(
@@ -966,6 +972,7 @@ async def create_and_send_nonmember_event(
ignore_shadow_ban: bool = False,
outlier: bool = False,
depth: int | None = None,
+ delay_id: str | None = None,
) -> tuple[EventBase, int]:
"""
Creates an event, then sends it.
@@ -994,6 +1001,7 @@ async def create_and_send_nonmember_event(
depth: Override the depth used to order the event in the DAG.
Should normally be set to None, which will cause the depth to be calculated
based on the prev_events.
+ delay_id: The delay ID of this event, if it was a delayed event.
Returns:
The event, and its stream ordering (if deduplication happened,
@@ -1090,6 +1098,7 @@ async def create_and_send_nonmember_event(
ignore_shadow_ban=ignore_shadow_ban,
outlier=outlier,
depth=depth,
+ delay_id=delay_id,
)
async def _create_and_send_nonmember_event_locked(
@@ -1103,6 +1112,7 @@ async def _create_and_send_nonmember_event_locked(
ignore_shadow_ban: bool = False,
outlier: bool = False,
depth: int | None = None,
+ delay_id: str | None = None,
) -> tuple[EventBase, int]:
room_id = event_dict["room_id"]
@@ -1131,6 +1141,7 @@ async def _create_and_send_nonmember_event_locked(
state_event_ids=state_event_ids,
outlier=outlier,
depth=depth,
+ delay_id=delay_id,
)
context = await unpersisted_context.persist(event)
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index 0c6be727169..b2e678e90e9 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -408,6 +408,7 @@ async def _local_membership_update(
require_consent: bool = True,
outlier: bool = False,
origin_server_ts: int | None = None,
+ delay_id: str | None = None,
) -> tuple[str, int]:
"""
Internal membership update function to get an existing event or create
@@ -440,6 +441,7 @@ async def _local_membership_update(
opposed to being inline with the current DAG.
origin_server_ts: The origin_server_ts to use if a new event is created. Uses
the current timestamp if set to None.
+ delay_id: The delay ID of this event, if it was a delayed event.
Returns:
Tuple of event ID and stream ordering position
@@ -492,6 +494,7 @@ async def _local_membership_update(
depth=depth,
require_consent=require_consent,
outlier=outlier,
+ delay_id=delay_id,
)
context = await unpersisted_context.persist(event)
prev_state_ids = await context.get_prev_state_ids(
@@ -587,6 +590,7 @@ async def update_membership(
state_event_ids: list[str] | None = None,
depth: int | None = None,
origin_server_ts: int | None = None,
+ delay_id: str | None = None,
) -> tuple[str, int]:
"""Update a user's membership in a room.
@@ -617,6 +621,7 @@ async def update_membership(
based on the prev_events.
origin_server_ts: The origin_server_ts to use if a new event is created. Uses
the current timestamp if set to None.
+ delay_id: The delay ID of this event, if it was a delayed event.
Returns:
A tuple of the new event ID and stream ID.
@@ -679,6 +684,7 @@ async def update_membership(
state_event_ids=state_event_ids,
depth=depth,
origin_server_ts=origin_server_ts,
+ delay_id=delay_id,
)
return result
@@ -701,6 +707,7 @@ async def update_membership_locked(
state_event_ids: list[str] | None = None,
depth: int | None = None,
origin_server_ts: int | None = None,
+ delay_id: str | None = None,
) -> tuple[str, int]:
"""Helper for update_membership.
@@ -733,6 +740,7 @@ async def update_membership_locked(
based on the prev_events.
origin_server_ts: The origin_server_ts to use if a new event is created. Uses
the current timestamp if set to None.
+ delay_id: The delay ID of this event, if it was a delayed event.
Returns:
A tuple of the new event ID and stream ID.
@@ -943,6 +951,7 @@ async def update_membership_locked(
require_consent=require_consent,
outlier=outlier,
origin_server_ts=origin_server_ts,
+ delay_id=delay_id,
)
latest_event_ids = await self.store.get_prev_events_for_room(room_id)
@@ -1201,6 +1210,7 @@ async def update_membership_locked(
require_consent=require_consent,
outlier=outlier,
origin_server_ts=origin_server_ts,
+ delay_id=delay_id,
)
async def check_for_any_membership_in_room(
diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi
index 0add391c651..185f29694b0 100644
--- a/synapse/synapse_rust/events.pyi
+++ b/synapse/synapse_rust/events.pyi
@@ -38,6 +38,8 @@ class EventInternalMetadata:
txn_id: str
"""The transaction ID, if it was set when the event was created."""
+ delay_id: str
+ """The delay ID, set only if the event was a delayed event."""
token_id: int
"""The access token ID of the user who sent this event, if any."""
device_id: str
diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py
index efa69a393ad..da904ce1f51 100644
--- a/tests/rest/client/test_delayed_events.py
+++ b/tests/rest/client/test_delayed_events.py
@@ -22,7 +22,7 @@
from synapse.api.errors import Codes
from synapse.rest import admin
-from synapse.rest.client import delayed_events, login, room, versions
+from synapse.rest.client import delayed_events, login, room, sync, versions
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
@@ -59,6 +59,7 @@ class DelayedEventsTestCase(HomeserverTestCase):
delayed_events.register_servlets,
login.register_servlets,
room.register_servlets,
+ sync.register_servlets,
]
def default_config(self) -> JsonDict:
@@ -106,6 +107,9 @@ def test_delayed_state_events_are_sent_on_timeout(self) -> None:
self.user1_access_token,
)
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
+ delay_id = channel.json_body.get("delay_id")
+ assert delay_id is not None
+
events = self._get_delayed_events()
self.assertEqual(1, len(events), events)
content = self._get_delayed_event_content(events[0])
@@ -128,6 +132,56 @@ def test_delayed_state_events_are_sent_on_timeout(self) -> None:
)
self.assertEqual(setter_expected, content.get(setter_key), content)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, True)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
+ def test_delayed_member_events_are_sent_on_timeout(self) -> None:
+ channel = self.make_request(
+ "PUT",
+ _get_path_for_delayed_state(
+ self.room_id,
+ "m.room.member",
+ self.user2_user_id,
+ 900,
+ ),
+ {
+ "membership": "leave",
+ "reason": "Delayed kick",
+ },
+ self.user1_access_token,
+ )
+ self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
+ delay_id = channel.json_body.get("delay_id")
+ assert delay_id is not None
+
+ events = self._get_delayed_events()
+ self.assertEqual(1, len(events), events)
+ content = self._get_delayed_event_content(events[0])
+ self.assertEqual("leave", content.get("membership"), content)
+ self.assertEqual("Delayed kick", content.get("reason"), content)
+
+ content = self.helper.get_state(
+ self.room_id,
+ "m.room.member",
+ self.user1_access_token,
+ state_key=self.user2_user_id,
+ )
+ self.assertEqual("join", content.get("membership"), content)
+
+ self.reactor.advance(1)
+ self.assertListEqual([], self._get_delayed_events())
+ content = self.helper.get_state(
+ self.room_id,
+ "m.room.member",
+ self.user1_access_token,
+ state_key=self.user2_user_id,
+ )
+ self.assertEqual("leave", content.get("membership"), content)
+ self.assertEqual("Delayed kick", content.get("reason"), content)
+
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, True)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
def test_get_delayed_events_auth(self) -> None:
channel = self.make_request("GET", PATH_PREFIX)
self.assertEqual(HTTPStatus.UNAUTHORIZED, channel.code, channel.result)
@@ -254,6 +308,9 @@ def test_cancel_delayed_state_event(self, action_in_path: bool) -> None:
expect_code=HTTPStatus.NOT_FOUND,
)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, False)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
@parameterized.expand((True, False))
@unittest.override_config(
{"rc_delayed_event_mgmt": {"per_second": 0.5, "burst_count": 1}}
@@ -327,6 +384,9 @@ def test_send_delayed_state_event(
)
self.assertEqual(content_value, content.get(content_property_name), content)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, True)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
@parameterized.expand((True, False))
@unittest.override_config({"rc_message": {"per_second": 2.5, "burst_count": 3}})
def test_send_delayed_event_ratelimit(self, action_in_path: bool) -> None:
@@ -406,6 +466,9 @@ def test_restart_delayed_state_event(self, action_in_path: bool) -> None:
)
self.assertEqual(setter_expected, content.get(setter_key), content)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, True)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
@parameterized.expand((True, False))
@unittest.override_config(
{"rc_delayed_event_mgmt": {"per_second": 0.5, "burst_count": 1}}
@@ -450,6 +513,8 @@ def test_delayed_state_is_not_cancelled_by_new_state_from_same_user(
self.user1_access_token,
)
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
+ delay_id = channel.json_body.get("delay_id")
+ assert delay_id is not None
events = self._get_delayed_events()
self.assertEqual(1, len(events), events)
@@ -474,6 +539,9 @@ def test_delayed_state_is_not_cancelled_by_new_state_from_same_user(
)
self.assertEqual(setter_expected, content.get(setter_key), content)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, True)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
def test_delayed_state_is_cancelled_by_new_state_from_other_user(
self,
) -> None:
@@ -489,6 +557,8 @@ def test_delayed_state_is_cancelled_by_new_state_from_other_user(
self.user1_access_token,
)
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
+ delay_id = channel.json_body.get("delay_id")
+ assert delay_id is not None
events = self._get_delayed_events()
self.assertEqual(1, len(events), events)
@@ -513,6 +583,9 @@ def test_delayed_state_is_cancelled_by_new_state_from_other_user(
)
self.assertEqual(setter_expected, content.get(setter_key), content)
+ self._find_sent_delayed_event(self.user1_access_token, delay_id, False)
+ self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
+
def _get_delayed_events(self) -> list[JsonDict]:
channel = self.make_request(
"GET",
@@ -549,6 +622,39 @@ def _update_delayed_event(
body["action"] = action
return self.make_request("POST", path, body)
+ def _find_sent_delayed_event(
+ self, access_token: str, delay_id: str, should_find: bool
+ ) -> None:
+ """Call /sync and look for a synced event with a specified delay_id.
+ At most one event will ever have a matching delay_id.
+
+ Args:
+ access_token: The access token of the user to call /sync for.
+ delay_id: The delay_id to search for in synced events.
+ should_find: Whether /sync should include an event with a matching delay_id.
+ """
+ channel = self.make_request("GET", "/sync", access_token=access_token)
+ self.assertEqual(HTTPStatus.OK, channel.code)
+
+ rooms = channel.json_body["rooms"]
+ events = []
+ for membership in "join", "leave":
+ if membership in rooms:
+ events += rooms[membership][self.room_id]["timeline"]["events"]
+
+ found = False
+ for event in events:
+ if event["unsigned"].get("org.matrix.msc4140.delay_id") == delay_id:
+ if not should_find:
+ self.fail(
+ "Found event with matching delay_id, but expected to not find one"
+ )
+ if found:
+ self.fail("Found multiple events with matching delay_id")
+ found = True
+ if should_find and not found:
+ self.fail("Did not find event with matching delay_id")
+
def _get_path_for_delayed_state(
room_id: str, event_type: str, state_key: str, delay_ms: int
From eedd4c8796b7abdbeea5d6622e0c45c3056f5fe9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 18:30:42 +0100
Subject: [PATCH 067/121] Bump pyjwt from 2.11.0 to 2.12.0 (#19560)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.11.0 to 2.12.0.
Release notes
- Annotate PyJWKSet.keys for pyright by @tamird in
`[#1134](https://github.com/jpadilla/pyjwt/issues/1134)
<https://github.com/jpadilla/pyjwt/pull/1134>`__
- Close ``HTTPError`` response to prevent ``ResourceWarning`` on Python
3.14 by @veeceey in
`[#1133](https://github.com/jpadilla/pyjwt/issues/1133)
<https://github.com/jpadilla/pyjwt/pull/1133>`__
- Do not keep ``algorithms`` dict in PyJWK instances by @akx in
`[#1143](https://github.com/jpadilla/pyjwt/issues/1143)
<https://github.com/jpadilla/pyjwt/pull/1143>`__
- Validate the crit (Critical) Header Parameter defined in RFC 7515
§4.1.11. by @dmbs335 in `GHSA-752w-5fwx-jx9f
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-752w-5fwx-jx9f>`__
- Use PyJWK algorithm when encoding without explicit algorithm in
`[#1148](https://github.com/jpadilla/pyjwt/issues/1148)
<https://github.com/jpadilla/pyjwt/pull/1148>`__
Added
Docs: Add PyJWKClient API reference and document the
two-tier caching system (JWK Set cache and signing key LRU cache).
Commits
bd9700c
Use PyJWK algorithm when encoding without explicit algorithm (#1148)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/element-hq/synapse/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
poetry.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index f5088ea9b9f..0bcb88a0469 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2306,14 +2306,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
-version = "2.11.0"
+version = "2.12.0"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"},
- {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"},
+ {file = "pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e"},
+ {file = "pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02"},
]
[package.dependencies]
From cdd261b1c60b96fbe690e8bc0aa9dd6c76cb5530 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 17:35:35 +0000
Subject: [PATCH 068/121] Bump pyopenssl from 25.3.0 to 26.0.0 (#19574)
Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 25.3.0 to
26.0.0.
Changelog
Added support for using aws-lc instead of OpenSSL.
Properly raise an error if a DTLS cookie callback returned a cookie
longer than DTLS1_COOKIE_LENGTH bytes. Previously this
would result in a buffer-overflow. Credit to dark_haxor
for reporting the issue. CVE-2026-27459
Added OpenSSL.SSL.Connection.get_group_name to
determine which group name was negotiated.
Context.set_tlsext_servername_callback now handles
exceptions raised in the callback by calling sys.excepthook
and returning a fatal TLS alert. Previously, exceptions were silently
swallowed and the handshake would proceed as if the callback had
succeeded. Credit to Leury Castillo for reporting this
issue. CVE-2026-27448
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/element-hq/synapse/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
poetry.lock | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index 0bcb88a0469..15cc023f228 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2398,18 +2398,18 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "
[[package]]
name = "pyopenssl"
-version = "25.3.0"
+version = "26.0.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6"},
- {file = "pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329"},
+ {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"},
+ {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"},
]
[package.dependencies]
-cryptography = ">=45.0.7,<47"
+cryptography = ">=46.0.0,<47"
typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""}
[package.extras]
From a71c468b04f308809b489e230edf3a1e91f23d0c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 17:52:13 +0000
Subject: [PATCH 069/121] Bump the patches group with 2 updates (#19536)
Bumps the patches group with 2 updates:
[anyhow](https://github.com/dtolnay/anyhow) and
[pyo3-log](https://github.com/vorner/pyo3-log).
Updates `anyhow` from 1.0.101 to 1.0.102
Release notes
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Cargo.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index eed90d64714..d6945bfbb79 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13,9 +13,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.101"
+version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
@@ -844,9 +844,9 @@ dependencies = [
[[package]]
name = "pyo3-log"
-version = "0.13.2"
+version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f8bae9ad5ba08b0b0ed2bb9c2bdbaeccc69cafca96d78cf0fbcea0d45d122bb"
+checksum = "26c2ec80932c5c3b2d4fbc578c9b56b2d4502098587edb8bef5b6bfcad43682e"
dependencies = [
"arc-swap",
"log",
From 3aa948c50c1f0b1d6e87ea7f222cc155dea77e3b Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Mon, 16 Mar 2026 18:27:54 +0000
Subject: [PATCH 070/121] When Matrix Authentication Service (MAS) integration
is enabled, allow MAS to set the user locked status in Synapse. (#19554)
Companion PR:
https://github.com/element-hq/matrix-authentication-service/pull/5550
to 1) send this flag
and 2) provision users proactively when their lock status changes.
---
Currently Synapse and MAS have two independent user lock
implementations. This PR makes it so that MAS can push its lock status
to Synapse when 'provisioning' the user.
Having the lock status in Synapse is useful for removing users from the
user directory
when they are locked.
There is otherwise no authentication requirement to have it in Synapse;
the enforcement is done
by MAS at token introspection time.
---------
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19554.feature | 1 +
synapse/rest/synapse/mas/users.py | 15 +++++++
tests/rest/synapse/mas/test_users.py | 58 ++++++++++++++++++++++++++++
3 files changed, 74 insertions(+)
create mode 100644 changelog.d/19554.feature
diff --git a/changelog.d/19554.feature b/changelog.d/19554.feature
new file mode 100644
index 00000000000..d30d3da1f6c
--- /dev/null
+++ b/changelog.d/19554.feature
@@ -0,0 +1 @@
+When Matrix Authentication Service (MAS) integration is enabled, allow MAS to set the user locked status in Synapse.
\ No newline at end of file
diff --git a/synapse/rest/synapse/mas/users.py b/synapse/rest/synapse/mas/users.py
index 55c73375551..01db41bcfac 100644
--- a/synapse/rest/synapse/mas/users.py
+++ b/synapse/rest/synapse/mas/users.py
@@ -112,6 +112,18 @@ class PostBody(RequestBodyModel):
unset_emails: StrictBool = False
set_emails: list[StrictStr] | None = None
+ locked: StrictBool | None = None
+ """
+ True to lock user. False to unlock. None to leave the same.
+
+ This is mostly for informational purposes; if the user's account is locked in MAS
+ but not in Synapse, the token introspection response will prevent them from using
+ their account.
+
+ However, having a local copy of the locked state in Synapse is useful for excluding
+ the user from the user directory.
+ """
+
@model_validator(mode="before")
@classmethod
def validate_exclusive(cls, values: Any) -> Any:
@@ -206,6 +218,9 @@ async def _async_render_POST(
validated_at=current_time,
)
+ if body.locked is not None:
+ await self.store.set_user_locked_status(user_id.to_string(), body.locked)
+
if body.unset_avatar_url:
await self.profile_handler.set_avatar_url(
target_user=user_id,
diff --git a/tests/rest/synapse/mas/test_users.py b/tests/rest/synapse/mas/test_users.py
index f0f26a939c9..6f44761bb8b 100644
--- a/tests/rest/synapse/mas/test_users.py
+++ b/tests/rest/synapse/mas/test_users.py
@@ -362,6 +362,64 @@ def test_provision_user_invalid_json_types(self) -> None:
)
self.assertEqual(channel.code, 400, f"Should fail for content: {content}")
+ def test_lock_and_unlock(self) -> None:
+ store = self.hs.get_datastores().main
+
+ # Create a user in the locked state
+ alice = UserID("alice", "test")
+ channel = self.make_request(
+ "POST",
+ "/_synapse/mas/provision_user",
+ shorthand=False,
+ access_token=self.SHARED_SECRET,
+ content={
+ "localpart": alice.localpart,
+ "locked": True,
+ },
+ )
+ # This created the user, hence the 201 status code
+ self.assertEqual(channel.code, 201, channel.json_body)
+ self.assertEqual(channel.json_body, {})
+ self.assertTrue(
+ self.get_success(store.get_user_locked_status(alice.to_string()))
+ )
+
+ # Then transition from locked to unlocked
+ channel = self.make_request(
+ "POST",
+ "/_synapse/mas/provision_user",
+ shorthand=False,
+ access_token=self.SHARED_SECRET,
+ content={
+ "localpart": alice.localpart,
+ "locked": False,
+ },
+ )
+ # This updated the user, hence the 200 status code
+ self.assertEqual(channel.code, 200, channel.json_body)
+ self.assertEqual(channel.json_body, {})
+ self.assertFalse(
+ self.get_success(store.get_user_locked_status(alice.to_string()))
+ )
+
+ # And back from unlocked to locked
+ channel = self.make_request(
+ "POST",
+ "/_synapse/mas/provision_user",
+ shorthand=False,
+ access_token=self.SHARED_SECRET,
+ content={
+ "localpart": alice.localpart,
+ "locked": True,
+ },
+ )
+ # This updated the user, hence the 200 status code
+ self.assertEqual(channel.code, 200, channel.json_body)
+ self.assertEqual(channel.json_body, {})
+ self.assertTrue(
+ self.get_success(store.get_user_locked_status(alice.to_string()))
+ )
+
@skip_unless(HAS_AUTHLIB, "requires authlib")
class MasIsLocalpartAvailableResource(BaseTestCase):
From 6254e009bbcf5991a1490645a77a5bd4addbbb80 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Mon, 16 Mar 2026 21:56:16 -0500
Subject: [PATCH 071/121] Fix `Build and push complement image` CI job pointing
to non-existent image (#19523)
:x:
https://github.com/element-hq/synapse/actions/runs/22609655282/job/65509315002#step:8:39
```
Error response from daemon: No such image: complement-synapse:latest
```
Regressed in
https://github.com/element-hq/synapse/pull/19475#discussion_r2823157623
where we updated `complement.sh` to build `localhost/complement-synapse`
instead of `complement-synapse`.
---
.github/workflows/push_complement_image.yml | 1 +
changelog.d/19523.bugfix | 1 +
scripts-dev/complement.sh | 13 +++++++++----
3 files changed, 11 insertions(+), 4 deletions(-)
create mode 100644 changelog.d/19523.bugfix
diff --git a/.github/workflows/push_complement_image.yml b/.github/workflows/push_complement_image.yml
index 12b4720ca5a..40ff3ba0e0e 100644
--- a/.github/workflows/push_complement_image.yml
+++ b/.github/workflows/push_complement_image.yml
@@ -69,6 +69,7 @@ jobs:
run: |
for TAG in ${{ join(fromJson(steps.meta.outputs.json).tags, ' ') }}; do
echo "tag and push $TAG"
+ # `localhost/complement-synapse` should match the image created by `scripts-dev/complement.sh`
docker tag complement-synapse $TAG
docker push $TAG
done
diff --git a/changelog.d/19523.bugfix b/changelog.d/19523.bugfix
new file mode 100644
index 00000000000..e9f53c61bab
--- /dev/null
+++ b/changelog.d/19523.bugfix
@@ -0,0 +1 @@
+Fix `Build and push complement image` CI job pointing to non-existent image.
diff --git a/scripts-dev/complement.sh b/scripts-dev/complement.sh
index c65ae53df0d..a8a361fd4a6 100755
--- a/scripts-dev/complement.sh
+++ b/scripts-dev/complement.sh
@@ -38,17 +38,22 @@ set -e
# Tag local builds with a dummy registry namespace so that later builds may reference
# them exactly instead of accidentally pulling from a remote registry.
#
-# This is important as some storage drivers/types prefer remote images over local
-# (`containerd`) which causes problems as we're testing against some remote image that
-# doesn't include all of the changes that we're trying to test (be it locally or in a PR
-# in CI). This is spawning from a real-world problem where the GitHub runners were
+# This is important as some Docker storage drivers/types prefer remote images over local
+# (like `containerd`) which causes problems as we're testing against some remote image
+# that doesn't include all of the changes that we're trying to test (be it locally or in
+# a PR in CI). This is spawning from a real-world problem where the GitHub runners were
# updated to use Docker Engine 29.0.0+ which uses `containerd` by default for new
# installations.
+#
+# XXX: If the Docker image name changes, don't forget to update
+# `.github/workflows/push_complement_image.yml` as well
LOCAL_IMAGE_NAMESPACE=localhost
# The image tags for how these images will be stored in the registry
SYNAPSE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse"
SYNAPSE_WORKERS_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse-workers"
+# XXX: If the Docker image name changes, don't forget to update
+# `.github/workflows/push_complement_image.yml` as well
COMPLEMENT_SYNAPSE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/complement-synapse"
SYNAPSE_EDITABLE_IMAGE_PATH="$LOCAL_IMAGE_NAMESPACE/synapse-editable"
From c37a5bb4cdb8894bb87b2229992faa4daa29ee33 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Mon, 16 Mar 2026 22:20:56 -0500
Subject: [PATCH 072/121] Restore `localhost/complement-synapse` change from
#19523
See https://github.com/element-hq/synapse/pull/19523#discussion_r2944133700
---
.github/workflows/push_complement_image.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/push_complement_image.yml b/.github/workflows/push_complement_image.yml
index 40ff3ba0e0e..7793172e559 100644
--- a/.github/workflows/push_complement_image.yml
+++ b/.github/workflows/push_complement_image.yml
@@ -70,6 +70,6 @@ jobs:
for TAG in ${{ join(fromJson(steps.meta.outputs.json).tags, ' ') }}; do
echo "tag and push $TAG"
# `localhost/complement-synapse` should match the image created by `scripts-dev/complement.sh`
- docker tag complement-synapse $TAG
+ docker tag localhost/complement-synapse $TAG
docker push $TAG
done
From 8a6d9a8d4550c3b0f6232ef5a2a5bfa51fbc5534 Mon Sep 17 00:00:00 2001
From: Andrew Ferrazzutti
Date: Tue, 17 Mar 2026 10:36:07 -0400
Subject: [PATCH 073/121] Admin API docs: use consistent path param syntax
(#19307)
Always use `/` instead of sometimes using `/$param`
### Pull Request Checklist
* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
- Use markdown where necessary, mostly for `code blocks`.
- End with either a period (.) or an exclamation mark (!).
- Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
---
changelog.d/19307.doc | 1 +
docs/admin_api/user_admin_api.md | 12 ++++++------
2 files changed, 7 insertions(+), 6 deletions(-)
create mode 100644 changelog.d/19307.doc
diff --git a/changelog.d/19307.doc b/changelog.d/19307.doc
new file mode 100644
index 00000000000..a27e97f3d05
--- /dev/null
+++ b/changelog.d/19307.doc
@@ -0,0 +1 @@
+In the Admin API documentation, always express path parameters as `/` instead of as `/$param`.
diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md
index 72e0e8d91a5..14f86fa9768 100644
--- a/docs/admin_api/user_admin_api.md
+++ b/docs/admin_api/user_admin_api.md
@@ -600,7 +600,7 @@ Fetches the number of invites sent by the provided user ID across all rooms
after the given timestamp.
```
-GET /_synapse/admin/v1/users/$user_id/sent_invite_count
+GET /_synapse/admin/v1/users//sent_invite_count
```
**Parameters**
@@ -634,7 +634,7 @@ Fetches the number of rooms that the user joined after the given timestamp, even
if they have subsequently left/been banned from those rooms.
```
-GET /_synapse/admin/v1/users/$/cumulative_joined_room_count
```
**Parameters**
@@ -1439,7 +1439,7 @@ The request and response format is the same as the
The API is:
```
-GET /_synapse/admin/v1/auth_providers/$provider/users/$external_id
+GET /_synapse/admin/v1/auth_providers//users/
```
When a user matched the given ID for the given provider, an HTTP code `200` with a response body like the following is returned:
@@ -1478,7 +1478,7 @@ _Added in Synapse 1.68.0._
The API is:
```
-GET /_synapse/admin/v1/threepid/$medium/users/$address
+GET /_synapse/admin/v1/threepid//users/
```
When a user matched the given address for the given medium, an HTTP code `200` with a response body like the following is returned:
@@ -1522,7 +1522,7 @@ is provided to override the default and allow the admin to issue the redactions
The API is
```
-POST /_synapse/admin/v1/user/$user_id/redact
+POST /_synapse/admin/v1/user//redact
{
"rooms": ["!roomid1", "!roomid2"]
@@ -1571,7 +1571,7 @@ or until Synapse is restarted (whichever happens first).
The API is:
```
-GET /_synapse/admin/v1/user/redact_status/$redact_id
+GET /_synapse/admin/v1/user/redact_status/
```
A response body like the following is returned:
From 8ad7e8af81bd655deb2014a7df04cf218c8a829d Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Tue, 17 Mar 2026 09:43:05 -0500
Subject: [PATCH 074/121] Add some light labels to the `Processed request` logs
(#19548)
It's pretty hard to remember the order of all of these ambiguous
numbers. I assume they're not totally labeled already to cut down on the
length when scanning with your eyes. This just adds a few hints of what
each grouping is.
Spawning from [staring at some Synapse
logs](https://github.com/element-hq/matrix-hosted/issues/10631) and
cross-referencing the Synapse source code over and over.
---
changelog.d/19548.misc | 1 +
docs/upgrade.md | 8 ++++++++
docs/usage/administration/request_log.md | 4 ++--
synapse/http/site.py | 4 +++-
4 files changed, 14 insertions(+), 3 deletions(-)
create mode 100644 changelog.d/19548.misc
diff --git a/changelog.d/19548.misc b/changelog.d/19548.misc
new file mode 100644
index 00000000000..eed4d5d8e2c
--- /dev/null
+++ b/changelog.d/19548.misc
@@ -0,0 +1 @@
+Add a few labels to the number groupings in the `Processed request` logs.
diff --git a/docs/upgrade.md b/docs/upgrade.md
index 777e57c4926..aeae82c114a 100644
--- a/docs/upgrade.md
+++ b/docs/upgrade.md
@@ -146,6 +146,14 @@ No immediate change is necessary, however once the parameter is removed, modules
From this version, when the parameter is passed, an error such as
``Deprecated `deactivation` parameter passed to `set_displayname` Module API (value: False). This will break in 2027.`` will be logged. The method will otherwise continue to work.
+## Updated request log format (`Processed request: ...`)
+
+The [request log format](usage/administration/request_log.md) has slightly changed to
+include `ru=(...)` and `db=(...)` labels to better disambiguate the number groupings.
+Previously, these values appeared without labels.
+
+This only matters if you have third-party tooling that parses the Synapse logs.
+
# Upgrading to v1.146.0
## Drop support for Ubuntu 25.04 Plucky Puffin, and add support for 25.10 Questing Quokka
diff --git a/docs/usage/administration/request_log.md b/docs/usage/administration/request_log.md
index 6154108934f..7e3047eb62e 100644
--- a/docs/usage/administration/request_log.md
+++ b/docs/usage/administration/request_log.md
@@ -5,8 +5,8 @@ HTTP request logs are written by synapse (see [`synapse/http/site.py`](https://g
See the following for how to decode the dense data available from the default logging configuration.
```
-2020-10-01 12:00:00,000 - synapse.access.http.8008 - 311 - INFO - PUT-1000- 192.168.0.1 - 8008 - {another-matrix-server.com} Processed request: 0.100sec/-0.000sec (0.000sec, 0.000sec) (0.001sec/0.090sec/3) 11B !200 "PUT /_matrix/federation/v1/send/1600000000000 HTTP/1.1" "Synapse/1.20.1" [0 dbevts]
--AAAAAAAAAAAAAAAAAAAAA- -BBBBBBBBBBBBBBBBBBBBBB- -C- -DD- -EEEEEE- -FFFFFFFFF- -GG- -HHHHHHHHHHHHHHHHHHHHHHH- -IIIIII- -JJJJJJJ- -KKKKKK-, -LLLLLL- -MMMMMMM- -NNNNNN- O -P- -QQ- -RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR- -SSSSSSSSSSSS- -TTTTTT-
+2020-10-01 12:00:00,000 - synapse.access.http.8008 - 311 - INFO - PUT-1000- 192.168.0.1 - 8008 - {another-matrix-server.com} Processed request: 0.100sec/-0.000sec ru=(0.000sec, 0.000sec) db=(0.001sec/0.090sec/3) 11B !200 "PUT /_matrix/federation/v1/send/1600000000000 HTTP/1.1" "Synapse/1.20.1" [0 dbevts]
+-AAAAAAAAAAAAAAAAAAAAA- -BBBBBBBBBBBBBBBBBBBBBB- -C- -DD- -EEEEEE- -FFFFFFFFF- -GG- -HHHHHHHHHHHHHHHHHHHHHHH- -IIIIII- -JJJJJJJ- -KKKKKK-, -LLLLLL- -MMMMMM- -NNNNNN- O -P- -QQ- -RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR- -SSSSSSSSSSSS- -TTTTTT-
```
diff --git a/synapse/http/site.py b/synapse/http/site.py
index 6ced5b98b3c..9b7fd5c936e 100644
--- a/synapse/http/site.py
+++ b/synapse/http/site.py
@@ -638,10 +638,12 @@ def _finished_processing(self) -> None:
if authenticated_entity:
requester = f"{authenticated_entity}|{requester}"
+ # Updates to this log line should also be reflected in our docs,
+ # `docs/usage/administration/request_log.md`
self.synapse_site.access_logger.log(
log_level,
"%s - %s - {%s}"
- " Processed request: %.3fsec/%.3fsec (%.3fsec, %.3fsec) (%.3fsec/%.3fsec/%d)"
+ " Processed request: %.3fsec/%.3fsec ru=(%.3fsec, %.3fsec) db=(%.3fsec/%.3fsec/%d)"
' %sB %s "%s %s %s" "%s" [%d dbevts]',
self.get_client_ip_if_available(),
self.synapse_site.site_tag,
From 6a63f0dcd7e59df6acceb9ee8049325a99481c5b Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Mar 2026 15:45:28 +0100
Subject: [PATCH 075/121] Migrate dev dependencies to PEP 735 dependency groups
(#19490)
This moves the dev dependencies to PEP 735 dependency groups, to help us
move to standard project metadata, which will help us moving to `uv`
(#19566)
This requires poetry 2.2.0
---
.github/workflows/fix_lint.yaml | 2 +-
.github/workflows/latest_deps.yml | 2 +-
.github/workflows/tests.yml | 18 +++---
.github/workflows/twisted_trunk.yml | 4 +-
changelog.d/19490.misc | 1 +
debian/build_virtualenv | 2 +-
debian/changelog | 1 +
docker/Dockerfile | 2 +-
docs/development/dependencies.md | 2 +-
mypy.ini | 2 +-
poetry.lock | 2 +-
pyproject.toml | 93 +++++++++++++++--------------
12 files changed, 68 insertions(+), 63 deletions(-)
create mode 100644 changelog.d/19490.misc
diff --git a/.github/workflows/fix_lint.yaml b/.github/workflows/fix_lint.yaml
index babc3bc5de1..4752b6afebd 100644
--- a/.github/workflows/fix_lint.yaml
+++ b/.github/workflows/fix_lint.yaml
@@ -31,7 +31,7 @@ jobs:
uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
install-project: "false"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
- name: Run ruff check
continue-on-error: true
diff --git a/.github/workflows/latest_deps.yml b/.github/workflows/latest_deps.yml
index 5bc78062cda..2d945c2096b 100644
--- a/.github/workflows/latest_deps.yml
+++ b/.github/workflows/latest_deps.yml
@@ -54,7 +54,7 @@ jobs:
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
python-version: "3.x"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: "all"
# Dump installed versions for debugging.
- run: poetry run pip list > before.txt
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index b39c453959f..a03d27472d7 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -95,7 +95,7 @@ jobs:
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
python-version: "3.x"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: "all"
- run: poetry run scripts-dev/generate_sample_config.sh --check
- run: poetry run scripts-dev/config-lint.sh
@@ -134,7 +134,7 @@ jobs:
- name: Setup Poetry
uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
install-project: "false"
- name: Run ruff check
@@ -169,7 +169,7 @@ jobs:
# https://github.com/matrix-org/synapse/pull/15376#issuecomment-1498983775
# To make CI green, err towards caution and install the project.
install-project: "true"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
# Cribbed from
# https://github.com/AustinScola/mypy-cache-github-action/blob/85ea4f2972abed39b33bd02c36e341b28ca59213/src/restore.ts#L10-L17
@@ -265,7 +265,7 @@ jobs:
# Install like a normal project from source with all optional dependencies
extras: all
install-project: "true"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
- name: Ensure `Cargo.lock` is up to date (no stray changes after install)
# The `::error::` syntax is using GitHub Actions' error annotations, see
@@ -398,7 +398,7 @@ jobs:
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
python-version: ${{ matrix.job.python-version }}
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: ${{ matrix.job.extras }}
- name: Await PostgreSQL
if: ${{ matrix.job.postgres-version }}
@@ -500,7 +500,7 @@ jobs:
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
python-version: ${{ matrix.python-version }}
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: ${{ matrix.extras }}
- run: poetry run trial --jobs=2 tests
- name: Dump logs
@@ -595,7 +595,7 @@ jobs:
- run: sudo apt-get -qq install xmlsec1 postgresql-client
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: "postgres"
- run: .ci/scripts/test_export_data_command.sh
env:
@@ -648,7 +648,7 @@ jobs:
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
python-version: ${{ matrix.python-version }}
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
extras: "postgres"
- run: .ci/scripts/test_synapse_port_db.sh
id: run_tester_script
@@ -708,7 +708,7 @@ jobs:
# We use `poetry` in `complement.sh`
- uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
with:
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
# Matches the `path` where we checkout Synapse above
working-directory: "synapse"
diff --git a/.github/workflows/twisted_trunk.yml b/.github/workflows/twisted_trunk.yml
index 12fdbbe7c47..d38e38ebcb5 100644
--- a/.github/workflows/twisted_trunk.yml
+++ b/.github/workflows/twisted_trunk.yml
@@ -54,7 +54,7 @@ jobs:
with:
python-version: "3.x"
extras: "all"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
- run: |
poetry remove twisted
poetry add --extras tls git+https://github.com/twisted/twisted.git#${{ inputs.twisted_ref || 'trunk' }}
@@ -82,7 +82,7 @@ jobs:
with:
python-version: "3.x"
extras: "all test"
- poetry-version: "2.1.1"
+ poetry-version: "2.2.1"
- run: |
poetry remove twisted
poetry add --extras tls git+https://github.com/twisted/twisted.git#trunk
diff --git a/changelog.d/19490.misc b/changelog.d/19490.misc
new file mode 100644
index 00000000000..924197a1cbc
--- /dev/null
+++ b/changelog.d/19490.misc
@@ -0,0 +1 @@
+Migrate `dev` dependencies to [PEP 735](https://peps.python.org/pep-0735/) dependency groups.
diff --git a/debian/build_virtualenv b/debian/build_virtualenv
index 70d4efcbd0d..7bbf52ddd97 100755
--- a/debian/build_virtualenv
+++ b/debian/build_virtualenv
@@ -35,7 +35,7 @@ TEMP_VENV="$(mktemp -d)"
python3 -m venv "$TEMP_VENV"
source "$TEMP_VENV/bin/activate"
pip install -U pip
-pip install poetry==2.1.1 poetry-plugin-export==1.9.0
+pip install poetry==2.2.1 poetry-plugin-export==1.9.0
poetry export \
--extras all \
--extras test \
diff --git a/debian/changelog b/debian/changelog
index 9cd39ee76c0..1a21a105b87 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,6 +1,7 @@
matrix-synapse-py3 (1.149.1+nmu1) UNRELEASED; urgency=medium
* Change how the systemd journald integration is installed.
+ * Update Poetry used at build time to 2.2.1.
-- Quentin Gliech Fri, 20 Feb 2026 19:19:51 +0100
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 59771ae88fe..6070d5c3558 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -22,7 +22,7 @@
ARG DEBIAN_VERSION=trixie
ARG PYTHON_VERSION=3.13
-ARG POETRY_VERSION=2.1.1
+ARG POETRY_VERSION=2.2.1
###
### Stage 0: generate requirements.txt
diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md
index 1b3348703f4..fe0667194a8 100644
--- a/docs/development/dependencies.md
+++ b/docs/development/dependencies.md
@@ -6,7 +6,7 @@ This is a quick cheat sheet for developers on how to use [`poetry`](https://pyth
See the [contributing guide](contributing_guide.md#4-install-the-dependencies).
-Developers should use Poetry 1.3.2 or higher. If you encounter problems related
+Developers should use Poetry 2.2.0 or higher. If you encounter problems related
to poetry, please [double-check your poetry version](#check-the-version-of-poetry-with-poetry---version).
# Background
diff --git a/mypy.ini b/mypy.ini
index d6a34342933..ec73ce9f6e7 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -69,7 +69,7 @@ warn_unused_ignores = False
;; https://github.com/python/typeshed/tree/master/stubs
;; and for each package `foo` there's a corresponding `types-foo` package on PyPI,
;; which we can pull in as a dev dependency by adding to `pyproject.toml`'s
-;; `[tool.poetry.group.dev.dependencies]` list.
+;; `[dependency-groups]` `dev` list.
# https://github.com/lepture/authlib/issues/460
[mypy-authlib.*]
diff --git a/poetry.lock b/poetry.lock
index 15cc023f228..681a183b422 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3754,4 +3754,4 @@ url-preview = ["lxml"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10.0,<4.0.0"
-content-hash = "dd63614889e7e181fca33760741a490e65fe4ef4f42756cafd0f804ae7324916"
+content-hash = "ce9ac9da9e7ffaf24b3e1e7892342ba486e7af4ea25385f875d0f3a2d5c5d133"
diff --git a/pyproject.toml b/pyproject.toml
index 07ab1ee5dbf..f655a1ef8be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -224,6 +224,9 @@ update_synapse_database = "synapse._scripts.update_synapse_database:main"
[tool.poetry]
packages = [{ include = "synapse" }]
+# We're using PEP 735 dependency groups, which requires Poetry 2.2.0+
+requires-poetry = ">=2.2.0"
+
[tool.poetry.build]
# Compile our rust module when using `poetry install`. This is still required
# while using `poetry` as the build frontend. Saves the developer from needing
@@ -251,55 +254,55 @@ generate-setup-file = true
# Dependencies used for developing Synapse itself.
#
-# Hold off on migrating these to `dev-dependencies` (PEP 735) for now until
-# Poetry 2.2.0+, pip 25.1+ are more widely available.
-[tool.poetry.group.dev.dependencies]
# We pin development dependencies in poetry.lock so that our tests don't start
# failing on new releases. Keeping lower bounds loose here means that dependabot
# can bump versions without having to update the content-hash in the lockfile.
# This helps prevents merge conflicts when running a batch of dependabot updates.
-ruff = "0.14.6"
-
-# Typechecking
-lxml-stubs = ">=0.4.0"
-mypy = "*"
-mypy-zope = "*"
-types-bleach = ">=4.1.0"
-types-jsonschema = ">=3.2.0"
-types-netaddr = ">=0.8.0.6"
-types-opentracing = ">=2.4.2"
-types-Pillow = ">=8.3.4"
-types-psycopg2 = ">=2.9.9"
-types-pyOpenSSL = ">=20.0.7"
-types-PyYAML = ">=5.4.10"
-types-requests = ">=2.26.0"
-types-setuptools = ">=57.4.0"
-
-# Dependencies which are exclusively required by unit test code. This is
-# NOT a list of all modules that are necessary to run the unit tests.
-# Tests assume that all optional dependencies are installed.
-#
-# If this is updated, don't forget to update the equivalent lines in
-# project.optional-dependencies.test.
-parameterized = ">=0.9.0"
-idna = ">=3.3"
-
-# The following are used by the release script
-click = ">=8.1.3"
-# GitPython was == 3.1.14; bumped to 3.1.20, the first release with type hints.
-GitPython = ">=3.1.20"
-markdown-it-py = ">=3.0.0"
-pygithub = ">=1.59"
-# The following are executed as commands by the release script.
-twine = "*"
-# Towncrier min version comes from https://github.com/matrix-org/synapse/pull/3425. Rationale unclear.
-towncrier = ">=18.6.0rc1"
-
-# Used for checking the Poetry lockfile
-tomli = ">=1.2.3"
-
-# Used for checking the schema delta files
-sqlglot = ">=28.0.0"
+[dependency-groups]
+dev = [
+ "ruff==0.14.6",
+
+ # Typechecking
+ "lxml-stubs>=0.4.0",
+ "mypy",
+ "mypy-zope",
+ "types-bleach>=4.1.0",
+ "types-jsonschema>=3.2.0",
+ "types-netaddr>=0.8.0.6",
+ "types-opentracing>=2.4.2",
+ "types-Pillow>=8.3.4",
+ "types-psycopg2>=2.9.9",
+ "types-pyOpenSSL>=20.0.7",
+ "types-PyYAML>=5.4.10",
+ "types-requests>=2.26.0",
+ "types-setuptools>=57.4.0",
+
+ # Dependencies which are exclusively required by unit test code. This is
+ # NOT a list of all modules that are necessary to run the unit tests.
+ # Tests assume that all optional dependencies are installed.
+ #
+ # If this is updated, don't forget to update the equivalent lines in
+ # project.optional-dependencies.test.
+ "parameterized>=0.9.0",
+ "idna>=3.3",
+
+ # The following are used by the release script
+ "click>=8.1.3",
+ # GitPython was == 3.1.14; bumped to 3.1.20, the first release with type hints.
+ "GitPython>=3.1.20",
+ "markdown-it-py>=3.0.0",
+ "pygithub>=1.59",
+ # The following are executed as commands by the release script.
+ "twine",
+ # Towncrier min version comes from https://github.com/matrix-org/synapse/pull/3425. Rationale unclear.
+ "towncrier>=18.6.0rc1",
+
+ # Used for checking the Poetry lockfile
+ "tomli>=1.2.3",
+
+ # Used for checking the schema delta files
+ "sqlglot>=28.0.0",
+]
[tool.towncrier]
package = "synapse"
From 7d8e8747eade7314f083ff4c9d9ef2d0becb5124 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 17 Mar 2026 15:56:55 +0100
Subject: [PATCH 076/121] 1.150.0rc1
---
CHANGES.md | 33 +++++++++++++++++++++++++++++++
changelog.d/19307.doc | 1 -
changelog.d/19311.misc | 1 -
changelog.d/19314.feature | 1 -
changelog.d/19476.misc | 1 -
changelog.d/19479.feature | 1 -
changelog.d/19487.feature | 1 -
changelog.d/19490.misc | 1 -
changelog.d/19491.misc | 1 -
changelog.d/19517.doc | 1 -
changelog.d/19518.doc | 1 -
changelog.d/19523.bugfix | 1 -
changelog.d/19527.misc | 1 -
changelog.d/19530.misc | 1 -
changelog.d/19542.bugfix | 1 -
changelog.d/19548.misc | 1 -
changelog.d/19554.feature | 1 -
debian/changelog | 8 ++++++--
pyproject.toml | 2 +-
schema/synapse-config.schema.yaml | 2 +-
20 files changed, 41 insertions(+), 20 deletions(-)
delete mode 100644 changelog.d/19307.doc
delete mode 100644 changelog.d/19311.misc
delete mode 100644 changelog.d/19314.feature
delete mode 100644 changelog.d/19476.misc
delete mode 100644 changelog.d/19479.feature
delete mode 100644 changelog.d/19487.feature
delete mode 100644 changelog.d/19490.misc
delete mode 100644 changelog.d/19491.misc
delete mode 100644 changelog.d/19517.doc
delete mode 100644 changelog.d/19518.doc
delete mode 100644 changelog.d/19523.bugfix
delete mode 100644 changelog.d/19527.misc
delete mode 100644 changelog.d/19530.misc
delete mode 100644 changelog.d/19542.bugfix
delete mode 100644 changelog.d/19548.misc
delete mode 100644 changelog.d/19554.feature
diff --git a/CHANGES.md b/CHANGES.md
index 1a5ff136c8a..e7c24162385 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,36 @@
+# Synapse 1.150.0rc1 (2026-03-17)
+
+## Features
+
+- Add experimental support for the [MSC4370](https://github.com/matrix-org/matrix-spec-proposals/pull/4370) Federation API `GET /extremities` endpoint. ([\#19314](https://github.com/element-hq/synapse/issues/19314))
+- [MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): When persisting a delayed event to the timeline, include its `delay_id` in the event's `unsigned` section in `/sync` responses to the event sender. ([\#19479](https://github.com/element-hq/synapse/issues/19479))
+- Expose [MSC4354 Sticky Events](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) over the legacy (v3) /sync API. ([\#19487](https://github.com/element-hq/synapse/issues/19487))
+- When Matrix Authentication Service (MAS) integration is enabled, allow MAS to set the user locked status in Synapse. ([\#19554](https://github.com/element-hq/synapse/issues/19554))
+
+## Bugfixes
+
+- Fix `Build and push complement image` CI job pointing to non-existent image. ([\#19523](https://github.com/element-hq/synapse/issues/19523))
+- Fix a bug introduced in v1.26.0 that caused deactivated, erased users to not be removed from the user directory. ([\#19542](https://github.com/element-hq/synapse/issues/19542))
+
+## Improved Documentation
+
+- In the Admin API documentation, always express path parameters as `/` instead of as `/$param`. ([\#19307](https://github.com/element-hq/synapse/issues/19307))
+- Update docs to clarify `outbound_federation_restricted_to` can also be used with the [Secure Border Gateway (SBG)](https://element.io/en/server-suite/secure-border-gateways). ([\#19517](https://github.com/element-hq/synapse/issues/19517))
+- Unify Complement developer docs. ([\#19518](https://github.com/element-hq/synapse/issues/19518))
+
+## Internal Changes
+
+- Put membership updates in a background resumable task when changing the avatar or the display name. ([\#19311](https://github.com/element-hq/synapse/issues/19311))
+- Add in-repo Complement test to sanity check Synapse version matches git checkout (testing what we think we are). ([\#19476](https://github.com/element-hq/synapse/issues/19476))
+- Migrate `dev` dependencies to [PEP 735](https://peps.python.org/pep-0735/) dependency groups. ([\#19490](https://github.com/element-hq/synapse/issues/19490))
+- Remove the optional `systemd-python` dependency and the `systemd` extra on the `synapse` package. ([\#19491](https://github.com/element-hq/synapse/issues/19491))
+- Avoid re-computing the event ID when cloning events. ([\#19527](https://github.com/element-hq/synapse/issues/19527))
+- Allow caching of the `/versions` and `/auth_metadata` public endpoints. ([\#19530](https://github.com/element-hq/synapse/issues/19530))
+- Add a few labels to the number groupings in the `Processed request` logs. ([\#19548](https://github.com/element-hq/synapse/issues/19548))
+
+
+
+
# Synapse 1.149.1 (2026-03-11)
## Internal Changes
diff --git a/changelog.d/19307.doc b/changelog.d/19307.doc
deleted file mode 100644
index a27e97f3d05..00000000000
--- a/changelog.d/19307.doc
+++ /dev/null
@@ -1 +0,0 @@
-In the Admin API documentation, always express path parameters as `/` instead of as `/$param`.
diff --git a/changelog.d/19311.misc b/changelog.d/19311.misc
deleted file mode 100644
index 66ec86c02d0..00000000000
--- a/changelog.d/19311.misc
+++ /dev/null
@@ -1 +0,0 @@
-Put membership updates in a background resumable task when changing the avatar or the display name.
diff --git a/changelog.d/19314.feature b/changelog.d/19314.feature
deleted file mode 100644
index fd2893c577d..00000000000
--- a/changelog.d/19314.feature
+++ /dev/null
@@ -1 +0,0 @@
-Add experimental support for the [MSC4370](https://github.com/matrix-org/matrix-spec-proposals/pull/4370) Federation API `GET /extremities` endpoint.
\ No newline at end of file
diff --git a/changelog.d/19476.misc b/changelog.d/19476.misc
deleted file mode 100644
index c1869911a47..00000000000
--- a/changelog.d/19476.misc
+++ /dev/null
@@ -1 +0,0 @@
-Add in-repo Complement test to sanity check Synapse version matches git checkout (testing what we think we are).
diff --git a/changelog.d/19479.feature b/changelog.d/19479.feature
deleted file mode 100644
index 3e7e8bd6ffb..00000000000
--- a/changelog.d/19479.feature
+++ /dev/null
@@ -1 +0,0 @@
-[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): When persisting a delayed event to the timeline, include its `delay_id` in the event's `unsigned` section in `/sync` responses to the event sender.
diff --git a/changelog.d/19487.feature b/changelog.d/19487.feature
deleted file mode 100644
index 4eb1d8f2617..00000000000
--- a/changelog.d/19487.feature
+++ /dev/null
@@ -1 +0,0 @@
-Expose [MSC4354 Sticky Events](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) over the legacy (v3) /sync API.
\ No newline at end of file
diff --git a/changelog.d/19490.misc b/changelog.d/19490.misc
deleted file mode 100644
index 924197a1cbc..00000000000
--- a/changelog.d/19490.misc
+++ /dev/null
@@ -1 +0,0 @@
-Migrate `dev` dependencies to [PEP 735](https://peps.python.org/pep-0735/) dependency groups.
diff --git a/changelog.d/19491.misc b/changelog.d/19491.misc
deleted file mode 100644
index 62b0ddddc2c..00000000000
--- a/changelog.d/19491.misc
+++ /dev/null
@@ -1 +0,0 @@
-Remove the optional `systemd-python` dependency and the `systemd` extra on the `synapse` package.
diff --git a/changelog.d/19517.doc b/changelog.d/19517.doc
deleted file mode 100644
index 778c14c6aac..00000000000
--- a/changelog.d/19517.doc
+++ /dev/null
@@ -1 +0,0 @@
-Update docs to clarify `outbound_federation_restricted_to` can also be used with the [Secure Border Gateway (SBG)](https://element.io/en/server-suite/secure-border-gateways).
diff --git a/changelog.d/19518.doc b/changelog.d/19518.doc
deleted file mode 100644
index 5de867c8d7b..00000000000
--- a/changelog.d/19518.doc
+++ /dev/null
@@ -1 +0,0 @@
-Unify Complement developer docs.
diff --git a/changelog.d/19523.bugfix b/changelog.d/19523.bugfix
deleted file mode 100644
index e9f53c61bab..00000000000
--- a/changelog.d/19523.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix `Build and push complement image` CI job pointing to non-existent image.
diff --git a/changelog.d/19527.misc b/changelog.d/19527.misc
deleted file mode 100644
index c349af286b1..00000000000
--- a/changelog.d/19527.misc
+++ /dev/null
@@ -1 +0,0 @@
-Avoid re-computing the event ID when cloning events.
diff --git a/changelog.d/19530.misc b/changelog.d/19530.misc
deleted file mode 100644
index 9e5bc0fe047..00000000000
--- a/changelog.d/19530.misc
+++ /dev/null
@@ -1 +0,0 @@
-Allow caching of the `/versions` and `/auth_metadata` public endpoints.
diff --git a/changelog.d/19542.bugfix b/changelog.d/19542.bugfix
deleted file mode 100644
index ab72504335f..00000000000
--- a/changelog.d/19542.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix a bug introduced in v1.26.0 that caused deactivated, erased users to not be removed from the user directory.
\ No newline at end of file
diff --git a/changelog.d/19548.misc b/changelog.d/19548.misc
deleted file mode 100644
index eed4d5d8e2c..00000000000
--- a/changelog.d/19548.misc
+++ /dev/null
@@ -1 +0,0 @@
-Add a few labels to the number groupings in the `Processed request` logs.
diff --git a/changelog.d/19554.feature b/changelog.d/19554.feature
deleted file mode 100644
index d30d3da1f6c..00000000000
--- a/changelog.d/19554.feature
+++ /dev/null
@@ -1 +0,0 @@
-When Matrix Authentication Service (MAS) integration is enabled, allow MAS to set the user locked status in Synapse.
\ No newline at end of file
diff --git a/debian/changelog b/debian/changelog
index 1a21a105b87..55af4c22b55 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,9 +1,13 @@
-matrix-synapse-py3 (1.149.1+nmu1) UNRELEASED; urgency=medium
+matrix-synapse-py3 (1.150.0~rc1) stable; urgency=medium
+ [ Quentin Gliech ]
* Change how the systemd journald integration is installed.
* Update Poetry used at build time to 2.2.1.
- -- Quentin Gliech Fri, 20 Feb 2026 19:19:51 +0100
+ [ Synapse Packaging team ]
+ * New synapse release 1.150.0rc1.
+
+ -- Synapse Packaging team Tue, 17 Mar 2026 14:56:35 +0000
matrix-synapse-py3 (1.149.1) stable; urgency=medium
diff --git a/pyproject.toml b/pyproject.toml
index f655a1ef8be..adb9993aae8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
-version = "1.149.1"
+version = "1.150.0rc1"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml
index 3a61a4c6fc9..dbf7d7acb7d 100644
--- a/schema/synapse-config.schema.yaml
+++ b/schema/synapse-config.schema.yaml
@@ -1,5 +1,5 @@
$schema: https://element-hq.github.io/synapse/latest/schema/v1/meta.schema.json
-$id: https://element-hq.github.io/synapse/schema/synapse/v1.149/synapse-config.schema.json
+$id: https://element-hq.github.io/synapse/schema/synapse/v1.150/synapse-config.schema.json
type: object
properties:
modules:
From d65ef848eb14f6e0c69e2a9614776a814d330244 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Tue, 17 Mar 2026 10:51:53 -0500
Subject: [PATCH 077/121] Fix `Build and push complement image` CI job not
having Poetry for `complement.sh` (#19578)
:x: `Build and push complement image`,
https://github.com/element-hq/synapse/actions/runs/23176317296/job/67339146082
```
scripts-dev/complement.sh: line 227: poetry: command not found
```
Follow-up to https://github.com/element-hq/synapse/pull/19523
This regressed in https://github.com/element-hq/synapse/pull/19476
### Testing strategy
1. Visit
https://github.com/element-hq/synapse/actions/workflows/push_complement_image.yml
1. **Run workflow**:
- **Use workflow from:**
`madlittlemods/fix-complement-push-image-ci-job-poetry`
- **Branch:** `develop`
1. Wait for CI to run and pass :white_check_mark:
---
.github/workflows/push_complement_image.yml | 4 ++++
changelog.d/19578.bugfix | 1 +
2 files changed, 5 insertions(+)
create mode 100644 changelog.d/19578.bugfix
diff --git a/.github/workflows/push_complement_image.yml b/.github/workflows/push_complement_image.yml
index 7793172e559..12599457dc7 100644
--- a/.github/workflows/push_complement_image.yml
+++ b/.github/workflows/push_complement_image.yml
@@ -47,6 +47,10 @@ jobs:
if: github.event_name == 'push'
with:
ref: master
+ # We use `poetry` in `complement.sh`
+ - uses: matrix-org/setup-python-poetry@5bbf6603c5c930615ec8a29f1b5d7d258d905aa4 # v2.0.0
+ with:
+ poetry-version: "2.2.1"
- name: Login to registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
diff --git a/changelog.d/19578.bugfix b/changelog.d/19578.bugfix
new file mode 100644
index 00000000000..dbaf4be7e89
--- /dev/null
+++ b/changelog.d/19578.bugfix
@@ -0,0 +1 @@
+Fix `Build and push complement image` CI job not having `poetry` available for the Complement runner script.
From 0d4accb0a6915e97384e0dcebe9c96c975a09f38 Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Tue, 17 Mar 2026 17:08:04 +0000
Subject: [PATCH 078/121] Remove support for MSC3852: Expose user agent
information on Device as the MSC was closed. (#19430)
Fixes: #14836
Discovered whilst looking at the state of MSCs in Synapse.
---------
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19430.removal | 1 +
synapse/config/experimental.py | 3 ---
synapse/handlers/device.py | 1 -
synapse/rest/client/devices.py | 24 ++++--------------------
4 files changed, 5 insertions(+), 24 deletions(-)
create mode 100644 changelog.d/19430.removal
diff --git a/changelog.d/19430.removal b/changelog.d/19430.removal
new file mode 100644
index 00000000000..8920bccc38a
--- /dev/null
+++ b/changelog.d/19430.removal
@@ -0,0 +1 @@
+Remove support for [MSC3852: Expose user agent information on Device](https://github.com/matrix-org/matrix-spec-proposals/pull/3852) as the MSC was closed.
\ No newline at end of file
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index a8c9305704e..41efff1d8f0 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -445,9 +445,6 @@ def read_config(
# MSC3848: Introduce errcodes for specific event sending failures
self.msc3848_enabled: bool = experimental.get("msc3848_enabled", False)
- # MSC3852: Expose last seen user agent field on /_matrix/client/v3/devices.
- self.msc3852_enabled: bool = experimental.get("msc3852_enabled", False)
-
# MSC3866: M_USER_AWAITING_APPROVAL error code
raw_msc3866_config = experimental.get("msc3866", {})
self.msc3866 = MSC3866Config(**raw_msc3866_config)
diff --git a/synapse/handlers/device.py b/synapse/handlers/device.py
index 29074f5e20c..9a371651fb0 100644
--- a/synapse/handlers/device.py
+++ b/synapse/handlers/device.py
@@ -129,7 +129,6 @@ def __init__(self, hs: "HomeServer"):
self._auth_handler = hs.get_auth_handler()
self._account_data_handler = hs.get_account_data_handler()
self._event_sources = hs.get_event_sources()
- self._msc3852_enabled = hs.config.experimental.msc3852_enabled
self._query_appservices_for_keys = (
hs.config.experimental.msc3984_appservice_key_query
)
diff --git a/synapse/rest/client/devices.py b/synapse/rest/client/devices.py
index b39ca6a4835..4b84131d326 100644
--- a/synapse/rest/client/devices.py
+++ b/synapse/rest/client/devices.py
@@ -55,7 +55,6 @@ def __init__(self, hs: "HomeServer"):
self.hs = hs
self.auth = hs.get_auth()
self.device_handler = hs.get_device_handler()
- self._msc3852_enabled = hs.config.experimental.msc3852_enabled
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request, allow_guest=True)
@@ -63,17 +62,10 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
requester.user.to_string()
)
- # If MSC3852 is disabled, then the "last_seen_user_agent" field will be
- # removed from each device. If it is enabled, then the field name will
- # be replaced by the unstable identifier.
- #
- # When MSC3852 is accepted, this block of code can just be removed to
- # expose "last_seen_user_agent" to clients.
for device in devices:
- last_seen_user_agent = device["last_seen_user_agent"]
+ # This field is only for admin access and should not be exposed to clients.
+ # (MSC3852, which is closed, did propose to expose it.).
del device["last_seen_user_agent"]
- if self._msc3852_enabled:
- device["org.matrix.msc3852.last_seen_user_agent"] = last_seen_user_agent
return 200, {"devices": devices}
@@ -144,7 +136,6 @@ def __init__(self, hs: "HomeServer"):
handler = hs.get_device_handler()
self.device_handler = handler
self.auth_handler = hs.get_auth_handler()
- self._msc3852_enabled = hs.config.experimental.msc3852_enabled
self._auth_delegation_enabled = (
hs.config.mas.enabled or hs.config.experimental.msc3861.enabled
)
@@ -159,16 +150,9 @@ async def on_GET(
if device is None:
raise NotFoundError("No device found")
- # If MSC3852 is disabled, then the "last_seen_user_agent" field will be
- # removed from each device. If it is enabled, then the field name will
- # be replaced by the unstable identifier.
- #
- # When MSC3852 is accepted, this block of code can just be removed to
- # expose "last_seen_user_agent" to clients.
- last_seen_user_agent = device["last_seen_user_agent"]
+ # This field is only for admin access and should not be exposed to clients.
+ # (MSC3852, which is closed, did propose to expose it.)
del device["last_seen_user_agent"]
- if self._msc3852_enabled:
- device["org.matrix.msc3852.last_seen_user_agent"] = last_seen_user_agent
return 200, device
From 3d960d88b38c93fa82be2e98e57c9f146460f056 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Wed, 18 Mar 2026 07:53:13 -0500
Subject: [PATCH 079/121] Add MSC3820 comment context to `RoomVersion`
attributes (#19577)
Spawning from
https://github.com/element-hq/synapse/pull/19424#discussion_r2855303614
---
changelog.d/19577.misc | 1 +
synapse/api/room_versions.py | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
create mode 100644 changelog.d/19577.misc
diff --git a/changelog.d/19577.misc b/changelog.d/19577.misc
new file mode 100644
index 00000000000..d26790e490d
--- /dev/null
+++ b/changelog.d/19577.misc
@@ -0,0 +1 @@
+Add MSC3820 comment context to `RoomVersion` attributes.
diff --git a/synapse/api/room_versions.py b/synapse/api/room_versions.py
index 2f98d7a8a86..13c7758638d 100644
--- a/synapse/api/room_versions.py
+++ b/synapse/api/room_versions.py
@@ -86,9 +86,9 @@ class RoomVersion:
# MSC2209: Check 'notifications' key while verifying
# m.room.power_levels auth rules.
limit_notifications_power_levels: bool
- # No longer include the creator in m.room.create events.
+ # MSC3820: No longer include the creator in m.room.create events (room version 11)
implicit_room_creator: bool
- # Apply updated redaction rules algorithm from room version 11.
+ # MSC3820: Apply updated redaction rules algorithm from room version 11
updated_redaction_rules: bool
# Support the 'restricted' join rule.
restricted_join_rule: bool
From 8201e58767333aca988b42ef0fee633d329a5b23 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Wed, 18 Mar 2026 16:29:36 +0200
Subject: [PATCH 080/121] Update and stabilize mutual rooms support (MSC2666)
(#19511)
Updates the error codes to match MSC2666 changes (user ID query param
validation + proper errcode for requesting rooms with self), added the
new `count` field, and stabilized the endpoint.
---
changelog.d/19511.feature | 1 +
synapse/config/experimental.py | 3 --
synapse/rest/client/mutual_rooms.py | 35 ++++++++++------
synapse/rest/client/versions.py | 2 +-
synapse/types/__init__.py | 58 ++++++++++++++++++++++++--
tests/rest/client/test_mutual_rooms.py | 54 ++++++++++++++----------
6 files changed, 110 insertions(+), 43 deletions(-)
create mode 100644 changelog.d/19511.feature
diff --git a/changelog.d/19511.feature b/changelog.d/19511.feature
new file mode 100644
index 00000000000..bb9a38b9c13
--- /dev/null
+++ b/changelog.d/19511.feature
@@ -0,0 +1 @@
+Update and stabilize support for [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666): Get rooms in common with another user. Contributed by @tulir @ Beeper.
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 41efff1d8f0..0e96b1dc6ac 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -422,9 +422,6 @@ def read_config(
# previously calculated push actions.
self.msc2654_enabled: bool = experimental.get("msc2654_enabled", False)
- # MSC2666: Query mutual rooms between two users.
- self.msc2666_enabled: bool = experimental.get("msc2666_enabled", False)
-
# MSC2815 (allow room moderators to view redacted event content)
self.msc2815_enabled: bool = experimental.get("msc2815_enabled", False)
diff --git a/synapse/rest/client/mutual_rooms.py b/synapse/rest/client/mutual_rooms.py
index a6a913db34d..d6783c15dab 100644
--- a/synapse/rest/client/mutual_rooms.py
+++ b/synapse/rest/client/mutual_rooms.py
@@ -29,7 +29,7 @@
from synapse.http.server import HttpServer
from synapse.http.servlet import RestServlet, parse_strings_from_args
from synapse.http.site import SynapseRequest
-from synapse.types import JsonDict
+from synapse.types import JsonDict, UserID
from ._base import client_patterns
@@ -65,13 +65,10 @@ def _parse_mutual_rooms_batch_token_args(args: dict[bytes, list[bytes]]) -> str
class UserMutualRoomsServlet(RestServlet):
"""
- GET /uk.half-shot.msc2666/user/mutual_rooms?user_id={user_id}&from={token} HTTP/1.1
+ GET /mutual_rooms?user_id={user_id}&from={token} HTTP/1.1
"""
- PATTERNS = client_patterns(
- "/uk.half-shot.msc2666/user/mutual_rooms$",
- releases=(), # This is an unstable feature
- )
+ PATTERNS = [*client_patterns("/mutual_rooms$", releases=("v1",))]
def __init__(self, hs: "HomeServer"):
super().__init__()
@@ -82,7 +79,9 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# twisted.web.server.Request.args is incorrectly defined as Any | None
args: dict[bytes, list[bytes]] = request.args # type: ignore
- user_ids = parse_strings_from_args(args, "user_id", required=True)
+ user_ids = parse_strings_from_args(
+ args, "user_id", required=True, encoding="utf-8"
+ )
from_batch = _parse_mutual_rooms_batch_token_args(args)
if len(user_ids) > 1:
@@ -93,13 +92,19 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
)
user_id = user_ids[0]
+ if not UserID.is_valid_strict(user_id):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Invalid user_id query parameter",
+ errcode=Codes.INVALID_PARAM,
+ )
requester = await self.auth.get_user_by_req(request)
if user_id == requester.user.to_string():
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"You cannot request a list of shared rooms with yourself",
- errcode=Codes.UNKNOWN,
+ errcode=Codes.INVALID_PARAM,
)
# Sort here instead of the database function, so that we don't expose
@@ -109,6 +114,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
frozenset((requester.user.to_string(), user_id))
)
)
+ total_count = len(rooms)
if from_batch:
# A from_batch token was provided, so cut off any rooms where the ID is
@@ -123,7 +129,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
if len(rooms) <= MUTUAL_ROOMS_BATCH_LIMIT:
# We've reached the end of the list, don't return a batch token
- return 200, {"joined": rooms}
+ return 200, {"joined": rooms, "count": total_count}
rooms = rooms[:MUTUAL_ROOMS_BATCH_LIMIT]
# We use urlsafe unpadded base64 encoding for the batch token in order to
@@ -135,11 +141,14 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# in the room ID. In the event that some silly user does that, don't let
# them paginate further.
if next_batch == from_batch:
- return 200, {"joined": rooms}
+ return 200, {"joined": rooms, "count": total_count}
- return 200, {"joined": list(rooms), "next_batch": next_batch}
+ return 200, {
+ "joined": rooms,
+ "next_batch": next_batch,
+ "count": total_count,
+ }
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
- if hs.config.experimental.msc2666_enabled:
- UserMutualRoomsServlet(hs).register(http_server)
+ UserMutualRoomsServlet(hs).register(http_server)
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index f8d7a1a4d9d..904da867941 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -144,7 +144,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# Implements additional endpoints as described in MSC2432
"org.matrix.msc2432": True,
# Implements additional endpoints as described in MSC2666
- "uk.half-shot.msc2666.query_mutual_rooms": self.config.experimental.msc2666_enabled,
+ "uk.half-shot.msc2666.query_mutual_rooms.stable": True,
# Whether new rooms will be set to encrypted or not (based on presets).
"io.element.e2ee_forced.public": self.e2ee_forced_public,
"io.element.e2ee_forced.private": self.e2ee_forced_private,
diff --git a/synapse/types/__init__.py b/synapse/types/__init__.py
index fb1f1192b70..02889795bbd 100644
--- a/synapse/types/__init__.py
+++ b/synapse/types/__init__.py
@@ -343,7 +343,7 @@ def is_valid(cls: type[DS], s: str) -> bool:
# possible for invalid data to exist in room-state, etc.
parse_and_validate_server_name(obj.domain)
return True
- except Exception:
+ except (SynapseError, ValueError):
return False
__repr__ = to_string
@@ -355,6 +355,29 @@ class UserID(DomainSpecificString):
SIGIL = "@"
+ @classmethod
+ def is_valid_strict(cls, s: str) -> bool:
+ """
+ Parses the input string and attempts to ensure it is a valid and compliant user
+ ID according to https://spec.matrix.org/v1.17/appendices/#historical-user-ids.
+
+ This should be used with care: there are existing non-compliant user IDs in the
+ wild with empty or non-ASCII localparts, which will be rejected by this method.
+ """
+ if len(s.encode("utf-8")) > 255:
+ return False
+ try:
+ obj = cls.from_string(s)
+ if not is_compliant_user_id_localpart(obj.localpart):
+ return False
+ # Apply additional validation to the domain. This is only done
+ # during is_valid (and not part of from_string) since it is
+ # possible for invalid data to exist in room-state, etc.
+ parse_and_validate_server_name(obj.domain)
+ return True
+ except (SynapseError, ValueError):
+ return False
+
@attr.s(slots=True, frozen=True, repr=False)
class RoomAlias(DomainSpecificString):
@@ -453,12 +476,17 @@ class EventID(DomainSpecificString):
def contains_invalid_mxid_characters(localpart: str) -> bool:
- """Check for characters not allowed in an mxid or groupid localpart
+ """
+ Check for characters not allowed in a modern user ID localpart.
+
+ This is primarily used for new registrations and MUST NOT be used to validate
+ existing user IDs, as there are real users whose user IDs don't follow this
+ character set.
+
+ See https://spec.matrix.org/v1.17/appendices/#user-identifiers
Args:
localpart: the localpart to be checked
- use_extended_character_set: True to use the extended allowed characters
- from MSC4009.
Returns:
True if there are any naughty characters
@@ -466,6 +494,28 @@ def contains_invalid_mxid_characters(localpart: str) -> bool:
return any(c not in MXID_LOCALPART_ALLOWED_CHARACTERS for c in localpart)
+def is_compliant_user_id_localpart(localpart: str) -> bool:
+ """
+ Validates that the given user ID localpart is within the "compliant" range,
+ i.e. not empty and all characters are between U+0021 and U+007E inclusive.
+ See https://spec.matrix.org/v1.17/appendices/#historical-user-ids
+
+ To check if a localpart is non-historical, use contains_invalid_mxid_characters instead.
+
+ This should be used with care: there are existing non-compliant user IDs in the
+ wild with empty or non-ASCII localparts, which will be rejected by this method.
+
+ Args:
+ localpart: the localpart to be checked
+
+ Returns:
+ True if the localpart is compliant, False otherwise
+ """
+ if not localpart:
+ return False
+ return all(0x21 <= ord(c) <= 0x7E for c in localpart)
+
+
UPPER_CASE_PATTERN = re.compile(b"[A-Z_]")
# the following is a pattern which matches '=', and bytes which are not allowed in a mxid
diff --git a/tests/rest/client/test_mutual_rooms.py b/tests/rest/client/test_mutual_rooms.py
index f78c67fcd97..cbaca17cddb 100644
--- a/tests/rest/client/test_mutual_rooms.py
+++ b/tests/rest/client/test_mutual_rooms.py
@@ -43,12 +43,6 @@ class UserMutualRoomsTest(unittest.HomeserverTestCase):
mutual_rooms.register_servlets,
]
- def default_config(self) -> dict:
- config = super().default_config()
- experimental = config.setdefault("experimental_features", {})
- experimental.setdefault("msc2666_enabled", True)
- return config
-
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
config = self.default_config()
return self.setup_test_homeserver(config=config)
@@ -62,27 +56,12 @@ def _get_mutual_rooms(
) -> FakeChannel:
return self.make_request(
"GET",
- "/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms"
+ "/_matrix/client/v1/mutual_rooms"
f"?user_id={quote(other_user)}"
+ (f"&from={quote(since_token)}" if since_token else ""),
access_token=token,
)
- @unittest.override_config({"experimental_features": {"msc2666_enabled": False}})
- def test_mutual_rooms_no_experimental_flag(self) -> None:
- """
- The endpoint should 404 if the experimental flag is not enabled.
- """
- # Register a user.
- u1 = self.register_user("user1", "pass")
- u1_token = self.login(u1, "pass")
-
- # Check that we're unable to query the endpoint due to the endpoint
- # being unrecognised.
- channel = self._get_mutual_rooms(u1_token, "@not-used:test")
- self.assertEqual(404, channel.code, channel.result)
- self.assertEqual("M_UNRECOGNIZED", channel.json_body["errcode"], channel.result)
-
def test_shared_room_list_public(self) -> None:
"""
A room should show up in the shared list of rooms between two users
@@ -129,6 +108,7 @@ def _check_mutual_rooms_with(
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 1)
+ self.assertEqual(channel.json_body["count"], 1)
self.assertEqual(channel.json_body["joined"][0], room_id_one)
# Create another room and invite user2 to it
@@ -142,6 +122,7 @@ def _check_mutual_rooms_with(
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 2)
+ self.assertEqual(channel.json_body["count"], 2)
for room_id_id in channel.json_body["joined"]:
self.assertIn(room_id_id, [room_id_one, room_id_two])
@@ -167,11 +148,13 @@ def test_shared_room_list_pagination_two_pages(self) -> None:
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids[0:10])
+ self.assertEqual(channel.json_body["count"], 15)
self.assertIn("next_batch", channel.json_body)
channel = self._get_mutual_rooms(u1_token, u2, channel.json_body["next_batch"])
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids[10:20])
+ self.assertEqual(channel.json_body["count"], 15)
self.assertNotIn("next_batch", channel.json_body)
def test_shared_room_list_pagination_one_page(self) -> None:
@@ -180,6 +163,7 @@ def test_shared_room_list_pagination_one_page(self) -> None:
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids)
+ self.assertEqual(channel.json_body["count"], 10)
self.assertNotIn("next_batch", channel.json_body)
def test_shared_room_list_pagination_invalid_token(self) -> None:
@@ -209,6 +193,7 @@ def test_shared_room_list_after_leave(self) -> None:
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 1)
+ self.assertEqual(channel.json_body["count"], 1)
self.assertEqual(channel.json_body["joined"][0], room)
self.helper.leave(room, user=u1, tok=u1_token)
@@ -217,11 +202,13 @@ def test_shared_room_list_after_leave(self) -> None:
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 0)
+ self.assertEqual(channel.json_body["count"], 0)
# Check user2's view of shared rooms with user1
channel = self._get_mutual_rooms(u2_token, u1)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 0)
+ self.assertEqual(channel.json_body["count"], 0)
def test_shared_room_list_nonexistent_user(self) -> None:
u1 = self.register_user("user1", "pass")
@@ -232,4 +219,27 @@ def test_shared_room_list_nonexistent_user(self) -> None:
channel = self._get_mutual_rooms(u1_token, "@meow:example.com")
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 0)
+ self.assertEqual(channel.json_body["count"], 0)
self.assertNotIn("next_batch", channel.json_body)
+
+ def test_shared_room_list_invalid_user(self) -> None:
+ u1 = self.register_user("user1", "pass")
+ u1_token = self.login(u1, "pass")
+
+ channel = self._get_mutual_rooms(u1_token, "@:example.com")
+ self.assertEqual(400, channel.code, channel.result)
+ self.assertEqual(
+ "M_INVALID_PARAM", channel.json_body["errcode"], channel.result
+ )
+
+ channel = self._get_mutual_rooms(u1_token, "@" + "a" * 255 + ":example.com")
+ self.assertEqual(400, channel.code, channel.result)
+ self.assertEqual(
+ "M_INVALID_PARAM", channel.json_body["errcode"], channel.result
+ )
+
+ channel = self._get_mutual_rooms(u1_token, "@🐈️:example.com")
+ self.assertEqual(400, channel.code, channel.result)
+ self.assertEqual(
+ "M_INVALID_PARAM", channel.json_body["errcode"], channel.result
+ )
From 261bfb786fd70c5d62237db291b090d606e9f723 Mon Sep 17 00:00:00 2001
From: Travis Ralston
Date: Wed, 18 Mar 2026 09:50:09 -0600
Subject: [PATCH 081/121] Fix zeroing out remote quarantined media count
(#19559)
Just something I noticed while working on
https://github.com/element-hq/synapse/pull/19558
We start the function by setting `total_media_quarantined` to zero, then
we do work on the `media_ids`, add the number affected, zero it out
(**bug**), do work on `hashes`, add the number of affected rows, then
return `total_media_quarantined`.
### Pull Request Checklist
* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
- Use markdown where necessary, mostly for `code blocks`.
- End with either a period (.) or an exclamation mark (!).
- Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
---
changelog.d/19559.bugfix | 1 +
synapse/storage/databases/main/room.py | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 changelog.d/19559.bugfix
diff --git a/changelog.d/19559.bugfix b/changelog.d/19559.bugfix
new file mode 100644
index 00000000000..f64a84e8e02
--- /dev/null
+++ b/changelog.d/19559.bugfix
@@ -0,0 +1 @@
+Fix quarantine media admin APIs sometimes returning inaccurate counts for remote media.
\ No newline at end of file
diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py
index 633df077367..7ac88e4c2aa 100644
--- a/synapse/storage/databases/main/room.py
+++ b/synapse/storage/databases/main/room.py
@@ -1217,7 +1217,6 @@ def _quarantine_remote_media_txn(
txn.execute(sql, [quarantined_by] + sql_args)
total_media_quarantined += txn.rowcount if txn.rowcount > 0 else 0
- total_media_quarantined = 0
if hashes:
sql_many_clause_sql, sql_many_clause_args = make_in_list_sql_clause(
txn.database_engine, "sha256", hashes
From edf5ce277adda9e0028decd0b9e131cd686b18d6 Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Wed, 18 Mar 2026 19:47:17 +0100
Subject: [PATCH 082/121] Allow using HTTP/2 over plaintext when introspecting
tokens with MAS (#19586)
---
changelog.d/19586.feature | 1 +
docs/usage/configuration/config_documentation.md | 2 ++
schema/synapse-config.schema.yaml | 7 +++++++
synapse/api/auth/mas.py | 1 +
synapse/config/mas.py | 2 ++
5 files changed, 13 insertions(+)
create mode 100644 changelog.d/19586.feature
diff --git a/changelog.d/19586.feature b/changelog.d/19586.feature
new file mode 100644
index 00000000000..a10bd2d2ef1
--- /dev/null
+++ b/changelog.d/19586.feature
@@ -0,0 +1 @@
+Introduce a [configuration option](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#matrix_authentication_service) to allow using HTTP/2 over plaintext when Synapse connects to Matrix Authentication Service.
diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index 1335def5857..48f33d54270 100644
--- a/docs/usage/configuration/config_documentation.md
+++ b/docs/usage/configuration/config_documentation.md
@@ -653,6 +653,8 @@ This setting has the following sub-options:
* `endpoint` (string): The URL where Synapse can reach MAS. This *must* have the `discovery` and `oauth` resources mounted. Defaults to `"http://localhost:8080"`.
+* `force_http2` (boolean): Force HTTP/2 over plaintext (H2C) when connecting to MAS. MAS supports this natively, but a reverse proxy between Synapse and MAS may not. Defaults to `false`.
+
* `secret` (string|null): A shared secret that will be used to authenticate requests from and to MAS.
* `secret_path` (string|null): Alternative to `secret`, reading the shared secret from a file. The file should be a plain text file, containing only the secret. Synapse reads the secret from the given file once at startup.
diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml
index dbf7d7acb7d..aaa306cee05 100644
--- a/schema/synapse-config.schema.yaml
+++ b/schema/synapse-config.schema.yaml
@@ -677,6 +677,13 @@ properties:
and `oauth` resources mounted.
default: http://localhost:8080
+ force_http2:
+ type: boolean
+ description: >-
+ Force HTTP/2 over plaintext (H2C) when connecting to MAS. MAS supports
+ this natively, but a reverse proxy between Synapse and MAS may not.
+ default: false
+
secret:
type: ["string", "null"]
description: >-
diff --git a/synapse/api/auth/mas.py b/synapse/api/auth/mas.py
index 79c15a53296..95c6d62a9d7 100644
--- a/synapse/api/auth/mas.py
+++ b/synapse/api/auth/mas.py
@@ -111,6 +111,7 @@ def __init__(self, hs: "HomeServer"):
self._rust_http_client = HttpClient(
reactor=hs.get_reactor(),
user_agent=self._http_client.user_agent.decode("utf8"),
+ http2_only=self._config.force_http2,
)
self._server_metadata = RetryOnExceptionCachedCall[ServerMetadata](
self._load_metadata
diff --git a/synapse/config/mas.py b/synapse/config/mas.py
index 104ba17ab79..6973e9ae58b 100644
--- a/synapse/config/mas.py
+++ b/synapse/config/mas.py
@@ -36,6 +36,7 @@
class MasConfigModel(ParseModel):
enabled: StrictBool = False
endpoint: AnyHttpUrl = AnyHttpUrl("http://localhost:8080")
+ force_http2: StrictBool = False
secret: StrictStr | None = Field(default=None)
# We set `strict=False` to allow `str` instances.
secret_path: FilePath | None = Field(default=None, strict=False)
@@ -82,6 +83,7 @@ def read_config(
self.enabled = parsed.enabled
self.endpoint = parsed.endpoint
+ self.force_http2 = parsed.force_http2
self._secret = parsed.secret
self._secret_path = parsed.secret_path
From 90697637a8f37faf898350318a231813a942bd74 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 18 Mar 2026 19:16:14 +0000
Subject: [PATCH 083/121] Bump actions/setup-go from 6.2.0 to 6.3.0 in the
minor-and-patches group (#19564)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/latest_deps.yml | 2 +-
.github/workflows/tests.yml | 2 +-
.github/workflows/twisted_trunk.yml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/latest_deps.yml b/.github/workflows/latest_deps.yml
index 2d945c2096b..450be20956f 100644
--- a/.github/workflows/latest_deps.yml
+++ b/.github/workflows/latest_deps.yml
@@ -207,7 +207,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a03d27472d7..987046b741f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -715,7 +715,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
diff --git a/.github/workflows/twisted_trunk.yml b/.github/workflows/twisted_trunk.yml
index d38e38ebcb5..74b4f11722c 100644
--- a/.github/workflows/twisted_trunk.yml
+++ b/.github/workflows/twisted_trunk.yml
@@ -180,7 +180,7 @@ jobs:
- name: Prepare Complement's Prerequisites
run: synapse/.ci/scripts/setup_complement_prerequisites.sh
- - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
cache-dependency-path: complement/go.sum
go-version-file: complement/go.mod
From 9fe7cbfe7f69a69fb0f445f16a9753a611c24e58 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 18 Mar 2026 19:24:28 +0000
Subject: [PATCH 084/121] Bump actions/upload-artifact from 6.0.0 to 7.0.0
(#19565)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/docker.yml | 2 +-
.github/workflows/docs-pr.yaml | 2 +-
.github/workflows/latest_deps.yml | 2 +-
.github/workflows/release-artifacts.yml | 6 +++---
.github/workflows/tests.yml | 4 ++--
.github/workflows/twisted_trunk.yml | 2 +-
6 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index b1dd8a75e77..a796f9cf2a8 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -108,7 +108,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: digests-${{ matrix.suffix }}
path: ${{ runner.temp }}/digests/*
diff --git a/.github/workflows/docs-pr.yaml b/.github/workflows/docs-pr.yaml
index 41f3664336f..cba12937436 100644
--- a/.github/workflows/docs-pr.yaml
+++ b/.github/workflows/docs-pr.yaml
@@ -39,7 +39,7 @@ jobs:
cp book/welcome_and_overview.html book/index.html
- name: Upload Artifact
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: book
path: book
diff --git a/.github/workflows/latest_deps.yml b/.github/workflows/latest_deps.yml
index 450be20956f..a85bad9d743 100644
--- a/.github/workflows/latest_deps.yml
+++ b/.github/workflows/latest_deps.yml
@@ -172,7 +172,7 @@ jobs:
if: ${{ always() }}
run: /sytest/scripts/tap_to_gha.pl /logs/results.tap
- name: Upload SyTest logs
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ always() }}
with:
name: Sytest Logs - ${{ job.status }} - (${{ join(matrix.*, ', ') }})
diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml
index 4343cb482f2..125d659f4e1 100644
--- a/.github/workflows/release-artifacts.yml
+++ b/.github/workflows/release-artifacts.yml
@@ -99,7 +99,7 @@ jobs:
echo "ARTIFACT_NAME=${DISTRO#*:}" >> "$GITHUB_OUTPUT"
- name: Upload debs as artifacts
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: debs-${{ steps.artifact-name.outputs.ARTIFACT_NAME }}
path: debs/*
@@ -150,7 +150,7 @@ jobs:
# musl: (TODO: investigate).
CIBW_TEST_SKIP: pp3*-* *musl*
- - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Wheel-${{ matrix.os }}
path: ./wheelhouse/*.whl
@@ -171,7 +171,7 @@ jobs:
- name: Build sdist
run: python -m build --sdist
- - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Sdist
path: dist/*.tar.gz
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 987046b741f..35c633b8edf 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -561,7 +561,7 @@ jobs:
if: ${{ always() }}
run: /sytest/scripts/tap_to_gha.pl /logs/results.tap
- name: Upload SyTest logs
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ always() }}
with:
name: Sytest Logs - ${{ job.status }} - (${{ join(matrix.job.*, ', ') }})
@@ -658,7 +658,7 @@ jobs:
PGPASSWORD: postgres
PGDATABASE: postgres
- name: "Upload schema differences"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ failure() && !cancelled() && steps.run_tester_script.outcome == 'failure' }}
with:
name: Schema dumps
diff --git a/.github/workflows/twisted_trunk.yml b/.github/workflows/twisted_trunk.yml
index 74b4f11722c..e39d64cfb2f 100644
--- a/.github/workflows/twisted_trunk.yml
+++ b/.github/workflows/twisted_trunk.yml
@@ -145,7 +145,7 @@ jobs:
if: ${{ always() }}
run: /sytest/scripts/tap_to_gha.pl /logs/results.tap
- name: Upload SyTest logs
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ always() }}
with:
name: Sytest Logs - ${{ job.status }} - (${{ join(matrix.*, ', ') }})
From f490c49c8580c6b404dd3dc311da30cc3cca9529 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 10:54:04 +0000
Subject: [PATCH 085/121] Bump pyasn1 from 0.6.2 to 0.6.3 (#19584)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
poetry.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index 681a183b422..36d3035277e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2076,14 +2076,14 @@ psycopg2 = "*"
[[package]]
name = "pyasn1"
-version = "0.6.2"
+version = "0.6.3"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
- {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
+ {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
+ {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
]
[[package]]
From 9edbf56969af7a4aa894237fa58bb1042947754b Mon Sep 17 00:00:00 2001
From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
Date: Thu, 19 Mar 2026 12:52:40 +0000
Subject: [PATCH 086/121] Prevent sending registration emails if registration
is disabled (#19585)
---
changelog.d/19585.misc | 1 +
synapse/rest/client/register.py | 9 +++++++
tests/rest/client/test_register.py | 40 +++++++++++++++++++++++++++++-
3 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/19585.misc
diff --git a/changelog.d/19585.misc b/changelog.d/19585.misc
new file mode 100644
index 00000000000..621cf782f14
--- /dev/null
+++ b/changelog.d/19585.misc
@@ -0,0 +1 @@
+Prevent sending registration emails if registration is disabled.
\ No newline at end of file
diff --git a/synapse/rest/client/register.py b/synapse/rest/client/register.py
index fdd2f1985a3..73832ba7a81 100644
--- a/synapse/rest/client/register.py
+++ b/synapse/rest/client/register.py
@@ -86,6 +86,7 @@ def __init__(self, hs: "HomeServer"):
self.server_name = hs.hostname
self.identity_handler = hs.get_identity_handler()
self.config = hs.config
+ self._registration_enabled = hs.config.registration.enable_registration
if self.hs.config.email.can_verify_email:
self.registration_mailer = Mailer(
@@ -109,6 +110,14 @@ async def on_POST(self, request: SynapseRequest) -> tuple[int, JsonDict]:
raise SynapseError(
400, "Email-based registration has been disabled on this server"
)
+
+ if not self._registration_enabled:
+ raise SynapseError(
+ 403,
+ "Registration is disabled on this homeserver",
+ Codes.FORBIDDEN,
+ )
+
body = parse_json_object_from_request(request)
assert_params_in_dict(body, ["client_secret", "email", "send_attempt"])
diff --git a/tests/rest/client/test_register.py b/tests/rest/client/test_register.py
index 2c0396a3de4..f66c56a6b19 100644
--- a/tests/rest/client/test_register.py
+++ b/tests/rest/client/test_register.py
@@ -22,7 +22,7 @@
import datetime
import importlib.resources as importlib_resources
import os
-from typing import Any
+from typing import Any, cast
from unittest.mock import AsyncMock
from twisted.internet.testing import MemoryReactor
@@ -66,6 +66,17 @@ def make_homeserver(
hs.get_send_email_handler()._sendmail = AsyncMock()
return hs
+ def _get_sendmail_mock(self) -> AsyncMock:
+ """
+ Cast the homeserver's `_sendmail` object as an `AsyncMock`.
+
+ `_sendmail` is an `AsyncMock` (see `make_homeserver`) but this type
+ information doesn't make it through the test harness. Thus we need to
+ cast the object again.
+ """
+ sendmail = self.hs.get_send_email_handler()._sendmail
+ return cast(AsyncMock, sendmail)
+
def test_POST_appservice_registration_valid(self) -> None:
user_id = "@as_user_kermit:test"
as_token = "i_am_an_app_service"
@@ -747,6 +758,33 @@ def test_request_token_existing_email_inhibit_error(self) -> None:
self.assertIsNotNone(channel.json_body.get("sid"))
+ @unittest.override_config(
+ {
+ "public_baseurl": "https://test_server",
+ "email": {
+ "smtp_host": "mail_server",
+ "smtp_port": 2525,
+ "notif_from": "sender@host",
+ },
+ }
+ )
+ def test_request_token_allowed_when_email_flow_is_advertised(self) -> None:
+ sendmail = self._get_sendmail_mock()
+ sendmail.reset_mock()
+
+ channel = self.make_request(
+ "POST",
+ b"register/email/requestToken",
+ {
+ "client_secret": "foobar",
+ "email": "test@example.com",
+ "send_attempt": 1,
+ },
+ )
+ self.assertEqual(200, channel.code, channel.result)
+ self.assertIsNotNone(channel.json_body.get("sid"))
+ sendmail.assert_awaited_once()
+
@unittest.override_config(
{
"public_baseurl": "https://test_server",
From 2c412ba24ae013c657128f6ff2256a4e23b181ae Mon Sep 17 00:00:00 2001
From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
Date: Fri, 20 Mar 2026 16:30:03 +0000
Subject: [PATCH 087/121] complement.sh: ensure old complement checkout files
are deleted; remove -N `wget` flag (#19592)
---
changelog.d/19592.misc | 1 +
scripts-dev/complement.sh | 12 +++++++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/19592.misc
diff --git a/changelog.d/19592.misc b/changelog.d/19592.misc
new file mode 100644
index 00000000000..e3fa3a5416b
--- /dev/null
+++ b/changelog.d/19592.misc
@@ -0,0 +1 @@
+Ensure old Complement test files are removed when downloading a Complement checkout via `./scripts-dev/complement.sh`.
\ No newline at end of file
diff --git a/scripts-dev/complement.sh b/scripts-dev/complement.sh
index a8a361fd4a6..cca87d42a9e 100755
--- a/scripts-dev/complement.sh
+++ b/scripts-dev/complement.sh
@@ -160,8 +160,18 @@ main() {
if [[ -z "$COMPLEMENT_DIR" ]]; then
COMPLEMENT_REF=${COMPLEMENT_REF:-main}
echo "COMPLEMENT_DIR not set. Fetching Complement checkout from ${COMPLEMENT_REF}..."
- wget -Nq https://github.com/matrix-org/complement/archive/${COMPLEMENT_REF}.tar.gz
+
+ # Download the Complement checkout at the specified ref.
+ wget -q https://github.com/matrix-org/complement/archive/${COMPLEMENT_REF}.tar.gz
+
+ # Delete the existing complement checkout. Otherwise we'll end up with stale
+ # test files after they're deleted server-side, and `tar` will not delete
+ # old files.
+ rm -rf complement-${COMPLEMENT_REF}
+
+ # Extract the checkout.
tar -xzf ${COMPLEMENT_REF}.tar.gz
+
COMPLEMENT_DIR=complement-${COMPLEMENT_REF}
echo "Checkout available at 'complement-${COMPLEMENT_REF}'"
fi
From b4282b82d0dbc3ba34954d442a581ad9331e465a Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith
Date: Fri, 20 Mar 2026 16:33:43 +0000
Subject: [PATCH 088/121] Updates for experimental MSC4388 support (sign-in
with QR code) (#19573)
---
changelog.d/19573.feature | 1 +
synapse/config/experimental.py | 4 +-
synapse/rest/client/rendezvous.py | 7 ++++
synapse/rest/client/versions.py | 2 -
tests/rest/client/test_msc4388_rendezvous.py | 42 ++++++++++++++------
5 files changed, 40 insertions(+), 16 deletions(-)
create mode 100644 changelog.d/19573.feature
diff --git a/changelog.d/19573.feature b/changelog.d/19573.feature
new file mode 100644
index 00000000000..69eca83681f
--- /dev/null
+++ b/changelog.d/19573.feature
@@ -0,0 +1 @@
+Updated experimental support for [MSC4388: Secure out-of-band channel for sign in with QR](https://github.com/matrix-org/matrix-spec-proposals/pull/4388).
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 0e96b1dc6ac..ff9b394eb9e 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -538,9 +538,9 @@ def read_config(
# See: https://github.com/element-hq/synapse/issues/19433
msc4388_mode = experimental.get("msc4388_mode", "off")
- if msc4388_mode not in ["off", "public", "authenticated"]:
+ if msc4388_mode not in ["off", "open", "authenticated"]:
raise ConfigError(
- "msc4388_mode must be one of 'off', 'public' or 'authenticated'",
+ "msc4388_mode must be one of 'off', 'open' or 'authenticated'",
("experimental", "msc4388_mode"),
)
self.msc4388_enabled: bool = msc4388_mode != "off"
diff --git a/synapse/rest/client/rendezvous.py b/synapse/rest/client/rendezvous.py
index bd9205fc5f0..246788609e2 100644
--- a/synapse/rest/client/rendezvous.py
+++ b/synapse/rest/client/rendezvous.py
@@ -20,6 +20,7 @@
#
import logging
+from http import HTTPStatus
from http.client import TEMPORARY_REDIRECT
from typing import TYPE_CHECKING, Any
@@ -81,6 +82,12 @@ def __init__(self, hs: "HomeServer") -> None:
hs.config.experimental.msc4388_requires_authentication
)
+ async def on_GET(self, request: SynapseRequest) -> tuple[int, Any]:
+ if self.require_authentication:
+ # This will raise if the user is not authenticated
+ await self.auth.get_user_by_req(request)
+ return HTTPStatus.OK, {"create_available": True}
+
async def on_POST(self, request: SynapseRequest) -> tuple[int, Any]:
if self.require_authentication:
# This will raise if the user is not authenticated
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 904da867941..7bf4b12e8b7 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -189,8 +189,6 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
is not None
)
),
- # MSC4388: Secure out-of-band channel for sign in with QR
- "io.element.msc4388": (self.config.experimental.msc4388_enabled),
# MSC4140: Delayed events
"org.matrix.msc4140": bool(self.config.server.max_event_delay_ms),
# Simplified sliding sync
diff --git a/tests/rest/client/test_msc4388_rendezvous.py b/tests/rest/client/test_msc4388_rendezvous.py
index f16bb3f344a..913a41dedb2 100644
--- a/tests/rest/client/test_msc4388_rendezvous.py
+++ b/tests/rest/client/test_msc4388_rendezvous.py
@@ -131,6 +131,10 @@ def test_disabled(self) -> None:
}
)
def test_off(self) -> None:
+ # Discovery endpoint should return 404
+ channel = self.make_request("GET", rz_endpoint, {}, access_token=None)
+ self.assertEqual(channel.code, 404)
+ # Create session should also fail
channel = self.make_request("POST", rz_endpoint, {}, access_token=None)
self.assertEqual(channel.code, 404)
@@ -143,7 +147,7 @@ def test_off(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -156,6 +160,11 @@ def test_rendezvous_public(self) -> None:
- Deleting the data
- Sequence token handling
"""
+ # Discovery should return 200
+ channel = self.make_request("GET", rz_endpoint, {}, access_token=None)
+ self.assertEqual(channel.code, 200)
+ self.assertTrue(channel.json_body.get("create_available"))
+
# We can post arbitrary data to the endpoint
channel = self.make_request(
"POST",
@@ -268,7 +277,11 @@ def test_rendezvous_requires_authentication(self) -> None:
self.setup_mock_oauth()
alice_token = self.register_oauth_user("alice", "device1")
- # This should fail without authentication:
+ # Discovery should fail due to lack of authentication
+ channel = self.make_request("GET", rz_endpoint, {}, access_token=None)
+ self.assertEqual(channel.code, 401)
+
+ # Creating a session should fail without authentication:
channel = self.make_request(
"POST",
rz_endpoint,
@@ -277,6 +290,11 @@ def test_rendezvous_requires_authentication(self) -> None:
)
self.assertEqual(channel.code, 401)
+ # Discovery should succeed with authentication
+ channel = self.make_request("GET", rz_endpoint, {}, access_token=alice_token)
+ self.assertEqual(channel.code, 200)
+ self.assertTrue(channel.json_body.get("create_available"))
+
# This should work as we are now authenticated
channel = self.make_request(
"POST",
@@ -288,7 +306,7 @@ def test_rendezvous_requires_authentication(self) -> None:
rendezvous_id = channel.json_body["id"]
sequence_token = channel.json_body["sequence_token"]
expires_in_ms = channel.json_body["expires_in_ms"]
- self.assertGreater(expires_in_ms, 0)
+ self.assertEqual(expires_in_ms, 120000)
session_endpoint = rz_endpoint + f"/{rendezvous_id}"
@@ -302,7 +320,7 @@ def test_rendezvous_requires_authentication(self) -> None:
self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["data"], "foo=bar")
self.assertEqual(channel.json_body["sequence_token"], sequence_token)
- self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 100)
# We can update the data without authentication
channel = self.make_request(
@@ -325,7 +343,7 @@ def test_rendezvous_requires_authentication(self) -> None:
self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["data"], "foo=baz")
self.assertEqual(channel.json_body["sequence_token"], new_sequence_token)
- self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 200)
+ self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 300)
# We can delete the data without authentication
channel = self.make_request(
@@ -355,7 +373,7 @@ def test_rendezvous_requires_authentication(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -402,7 +420,7 @@ def test_expiration(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -473,7 +491,7 @@ def test_capacity(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -531,7 +549,7 @@ def test_hard_capacity(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -585,7 +603,7 @@ def test_data_type(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -648,7 +666,7 @@ def test_max_length(self) -> None:
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
@@ -698,7 +716,7 @@ def test_rendezvous_rejects_unsafe_get_requests(
"endpoint": "https://issuer",
},
"experimental_features": {
- "msc4388_mode": "public",
+ "msc4388_mode": "open",
},
}
)
From 40d699b1d4d7855ffb892723eac90cd34f22aa6f Mon Sep 17 00:00:00 2001
From: Travis Ralston
Date: Fri, 20 Mar 2026 13:34:26 -0600
Subject: [PATCH 089/121] Stable support for MSC4284 policy servers (#19503)
Fixes https://github.com/element-hq/synapse/issues/19494
MSC4284 policy servers
This:
* removes the old `/check` (recommendation) support because it's from an
older design. Policy servers should have updated to `/sign` by now. We
also remove optionality around the policy server's public key because it
was only optional to support `/check`.
* supports the stable `m.room.policy` state event and `/sign` endpoints,
falling back to unstable if required. Note the changes between unstable
and stable:
* Stable `/sign` uses errors instead of an empty signatures block to
indicate refusal.
* Stable `m.room.policy` nests the public key in an object with explicit
key algorithm (always ed25519 for now)
* does *not* introduce tests that the above fallback to unstable works.
If it breaks, we're not going to be sad about an early transition. Tests
can be added upon request, though.
* fixes a bug where the policy server was asked to sign policy server
state events (the events were correctly skipped in `is_event_allowed`,
but `ask_policy_server_to_sign_event` didn't do the same).
* fixes a bug where the original event sender's signature can be deleted
if the sending server is the same as the policy server.
* proxies Matrix-shaped errors from the policy server to the
Client-Server API as `SynapseError`s (a new capability of the stable
API).
Membership event handling (from the issue) is expected to be a different
PR due to the size of changes involved (tracked by
https://github.com/element-hq/synapse/issues/19587).
### Pull Request Checklist
* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
- Use markdown where necessary, mostly for `code blocks`.
- End with either a period (.) or an exclamation mark (!).
- Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
---------
Co-authored-by: turt2live <1190097+turt2live@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Eric Eastwood
---
changelog.d/19503.bugfix.1 | 1 +
changelog.d/19503.bugfix.2 | 1 +
changelog.d/19503.feature | 1 +
synapse/api/constants.py | 2 +
synapse/federation/federation_client.py | 75 +------
synapse/federation/transport/client.py | 57 +++---
synapse/handlers/room_policy.py | 249 ++++++++++++++++--------
synapse/types/handlers/policy_server.py | 16 --
tests/handlers/test_room_policy.py | 96 ++++-----
9 files changed, 249 insertions(+), 249 deletions(-)
create mode 100644 changelog.d/19503.bugfix.1
create mode 100644 changelog.d/19503.bugfix.2
create mode 100644 changelog.d/19503.feature
delete mode 100644 synapse/types/handlers/policy_server.py
diff --git a/changelog.d/19503.bugfix.1 b/changelog.d/19503.bugfix.1
new file mode 100644
index 00000000000..45e3ad603b2
--- /dev/null
+++ b/changelog.d/19503.bugfix.1
@@ -0,0 +1 @@
+Fix [MSC4284](https://github.com/matrix-org/matrix-spec-proposals/pull/4284) Policy Servers implementation to skip signing `org.matrix.msc4284.policy` and `m.room.policy` state events.
\ No newline at end of file
diff --git a/changelog.d/19503.bugfix.2 b/changelog.d/19503.bugfix.2
new file mode 100644
index 00000000000..8a423ba785d
--- /dev/null
+++ b/changelog.d/19503.bugfix.2
@@ -0,0 +1 @@
+Correctly apply [MSC4284](https://github.com/matrix-org/matrix-spec-proposals/pull/4284) Policy Server signatures to events when the sender and policy server have the same server name.
\ No newline at end of file
diff --git a/changelog.d/19503.feature b/changelog.d/19503.feature
new file mode 100644
index 00000000000..20db8aeaae1
--- /dev/null
+++ b/changelog.d/19503.feature
@@ -0,0 +1 @@
+Add stable support for [MSC4284](https://github.com/matrix-org/matrix-spec-proposals/pull/4284) Policy Servers.
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index 21139a3867a..eb9e6cc39b9 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -158,6 +158,8 @@ class EventTypes:
PollStart: Final = "m.poll.start"
+ RoomPolicy: Final = "m.room.policy"
+
class ToDeviceEventTypes:
RoomKeyRequest: Final = "m.room_key_request"
diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py
index ba738ad65e7..55151ca549c 100644
--- a/synapse/federation/federation_client.py
+++ b/synapse/federation/federation_client.py
@@ -72,7 +72,6 @@
from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace
from synapse.metrics import SERVER_NAME_LABEL
from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id
-from synapse.types.handlers.policy_server import RECOMMENDATION_OK, RECOMMENDATION_SPAM
from synapse.util.async_helpers import concurrently_execute
from synapse.util.caches.expiringcache import ExpiringCache
from synapse.util.duration import Duration
@@ -438,72 +437,16 @@ async def _record_failure_callback(
return None
- @trace
- @tag_args
- async def get_pdu_policy_recommendation(
- self, destination: str, pdu: EventBase, timeout: int | None = None
- ) -> str:
- """Requests that the destination server (typically a policy server)
- check the event and return its recommendation on how to handle the
- event.
-
- If the policy server could not be contacted or the policy server
- returned an unknown recommendation, this returns an OK recommendation.
- This type fixing behaviour is done because the typical caller will be
- in a critical call path and would generally interpret a `None` or similar
- response as "weird value; don't care; move on without taking action". We
- just frontload that logic here.
-
-
- Args:
- destination: The remote homeserver to ask (a policy server)
- pdu: The event to check
- timeout: How long to try (in ms) the destination for before
- giving up. None indicates no timeout.
-
- Returns:
- The policy recommendation, or RECOMMENDATION_OK if the policy server was
- uncontactable or returned an unknown recommendation.
- """
-
- logger.debug(
- "get_pdu_policy_recommendation for event_id=%s from %s",
- pdu.event_id,
- destination,
- )
-
- try:
- res = await self.transport_layer.get_policy_recommendation_for_pdu(
- destination, pdu, timeout=timeout
- )
- recommendation = res.get("recommendation")
- if not isinstance(recommendation, str):
- raise InvalidResponseError("recommendation is not a string")
- if recommendation not in (RECOMMENDATION_OK, RECOMMENDATION_SPAM):
- logger.warning(
- "get_pdu_policy_recommendation: unknown recommendation: %s",
- recommendation,
- )
- return RECOMMENDATION_OK
- return recommendation
- except Exception as e:
- logger.warning(
- "get_pdu_policy_recommendation: server %s responded with error, assuming OK recommendation: %s",
- destination,
- e,
- )
- return RECOMMENDATION_OK
-
@trace
@tag_args
async def ask_policy_server_to_sign_event(
self, destination: str, pdu: EventBase, timeout: int | None = None
- ) -> JsonDict | None:
+ ) -> JsonDict:
"""Requests that the destination server (typically a policy server)
sign the event as not spam.
If the policy server could not be contacted or the policy server
- returned an error, this returns no signature.
+ returned an error, that error is raised.
Args:
destination: The remote homeserver to ask (a policy server)
@@ -519,17 +462,9 @@ async def ask_policy_server_to_sign_event(
pdu.event_id,
destination,
)
- try:
- return await self.transport_layer.ask_policy_server_to_sign_event(
- destination, pdu, timeout=timeout
- )
- except Exception as e:
- logger.warning(
- "ask_policy_server_to_sign_event: server %s responded with error: %s",
- destination,
- e,
- )
- return None
+ return await self.transport_layer.ask_policy_server_to_sign_event(
+ destination, pdu, timeout=timeout
+ )
@trace
@tag_args
diff --git a/synapse/federation/transport/client.py b/synapse/federation/transport/client.py
index 35d3c30c699..5d5212ef96c 100644
--- a/synapse/federation/transport/client.py
+++ b/synapse/federation/transport/client.py
@@ -47,6 +47,7 @@
)
from synapse.events import EventBase, make_event_from_dict
from synapse.federation.units import Transaction
+from synapse.http.client import is_unknown_endpoint
from synapse.http.matrixfederationclient import ByteParser, LegacyJsonSendParser
from synapse.http.types import QueryParams
from synapse.types import JsonDict, UserID
@@ -141,33 +142,6 @@ async def get_event(
destination, path=path, timeout=timeout, try_trailing_slash_on_400=True
)
- async def get_policy_recommendation_for_pdu(
- self, destination: str, event: EventBase, timeout: int | None = None
- ) -> JsonDict:
- """Requests the policy recommendation for the given pdu from the given policy server.
-
- Args:
- destination: The host name of the remote homeserver checking the event.
- event: The event to check.
- timeout: How long to try (in ms) the destination for before giving up.
- None indicates no timeout.
-
- Returns:
- The full recommendation object from the remote server.
- """
- logger.debug(
- "get_policy_recommendation_for_pdu dest=%s, event_id=%s",
- destination,
- event.event_id,
- )
- return await self.client.post_json(
- destination=destination,
- path=f"/_matrix/policy/unstable/org.matrix.msc4284/event/{event.event_id}/check",
- data=event.get_pdu_json(),
- ignore_backoff=True,
- timeout=timeout,
- )
-
async def ask_policy_server_to_sign_event(
self, destination: str, event: EventBase, timeout: int | None = None
) -> JsonDict:
@@ -186,13 +160,28 @@ async def ask_policy_server_to_sign_event(
The signature from the policy server, structured in the same was as the 'signatures'
JSON in the event e.g { "$policy_server_via_domain" : { "ed25519:policy_server": "signature_base64" }}
"""
- return await self.client.post_json(
- destination=destination,
- path="/_matrix/policy/unstable/org.matrix.msc4284/sign",
- data=event.get_pdu_json(),
- ignore_backoff=True,
- timeout=timeout,
- )
+ # Try stable first, then fall back to unstable if unsupported. All other errors
+ # are just errors.
+ try:
+ return await self.client.post_json(
+ destination=destination,
+ path="/_matrix/policy/v1/sign",
+ data=event.get_pdu_json(),
+ ignore_backoff=True,
+ timeout=timeout,
+ )
+ except HttpResponseException as ex:
+ if is_unknown_endpoint(ex):
+ # TODO: Remove unstable MSC4284 support
+ # https://github.com/element-hq/synapse/issues/19502
+ return await self.client.post_json(
+ destination=destination,
+ path="/_matrix/policy/unstable/org.matrix.msc4284/sign",
+ data=event.get_pdu_json(),
+ ignore_backoff=True,
+ timeout=timeout,
+ )
+ raise
async def backfill(
self, destination: str, room_id: str, event_tuples: Collection[str], limit: int
diff --git a/synapse/handlers/room_policy.py b/synapse/handlers/room_policy.py
index 0663a367144..57163c43444 100644
--- a/synapse/handlers/room_policy.py
+++ b/synapse/handlers/room_policy.py
@@ -17,13 +17,14 @@
import logging
from typing import TYPE_CHECKING
+import attr
from signedjson.key import decode_verify_key_bytes
from unpaddedbase64 import decode_base64
-from synapse.api.errors import SynapseError
+from synapse.api.constants import EventTypes
+from synapse.api.errors import Codes, HttpResponseException, SynapseError
from synapse.crypto.keyring import VerifyJsonRequest
from synapse.events import EventBase
-from synapse.types.handlers.policy_server import RECOMMENDATION_OK
from synapse.util.stringutils import parse_and_validate_server_name
if TYPE_CHECKING:
@@ -31,10 +32,18 @@
logger = logging.getLogger(__name__)
-POLICY_SERVER_EVENT_TYPE = "org.matrix.msc4284.policy"
POLICY_SERVER_KEY_ID = "ed25519:policy_server"
+@attr.s(slots=True, auto_attribs=True)
+class PolicyServerInfo:
+ # name of the server.
+ server_name: str
+
+ # the unpadded base64-encoded Ed25519 public key of the server.
+ public_key: str
+
+
class RoomPolicyHandler:
def __init__(self, hs: "HomeServer"):
self._hs = hs
@@ -43,77 +52,131 @@ def __init__(self, hs: "HomeServer"):
self._event_auth_handler = hs.get_event_auth_handler()
self._federation_client = hs.get_federation_client()
- async def is_event_allowed(self, event: EventBase) -> bool:
- """Check if the given event is allowed in the room by the policy server.
+ def _is_policy_server_state_event(self, event: EventBase) -> bool:
+ state_key = event.get_state_key()
+ if state_key is not None and state_key == "":
+ # TODO: Remove unstable MSC4284 support
+ # https://github.com/element-hq/synapse/issues/19502
+ # Note: we can probably drop this whole function when we remove unstable support
+ return event.type in [EventTypes.RoomPolicy, "org.matrix.msc4284.policy"]
+ return False
- Note: This will *always* return True if the room's policy server is Synapse
- itself. This is because Synapse can't be a policy server (currently).
+ async def _get_policy_server(self, room_id: str) -> PolicyServerInfo | None:
+ """Get the policy server's name and Ed25519 public key for the room, if set.
- If no policy server is configured in the room, this returns True. Similarly, if
- the policy server is invalid in any way (not joined, not a server, etc), this
- returns True.
+ If the `m.room.policy` state event is invalid, then a policy server is not set. It
+ can be invalid if:
+ - The room doesn't have an `m.room.policy` state event with empty state key.
+ - The policy state event is missing the `via` or `public_keys` field.
+ - The policy state event's public keys is missing an `ed25519` key.
+ - The via server is not a valid server name.
+ - The via server is not in the room.
+ - The via server is Synapse itself.
- If a valid and contactable policy server is configured in the room, this returns
- True if that server suggests the event is not spammy, and False otherwise.
+ TODO: Remove unstable MSC4284 support - https://github.com/element-hq/synapse/issues/19502
+ This function also checks for the unstable `org.matrix.msc4284.policy` state event.
Args:
- event: The event to check. This should be a fully-formed PDU.
+ room_id: The room ID to get the policy server for.
Returns:
- bool: True if the event is allowed in the room, False otherwise.
+ A tuple of policy server name and its Ed25519 public key (unpadded base64).
+ Both values will be None if no policy server is configured or the configration
+ is invalid.
"""
- if event.type == POLICY_SERVER_EVENT_TYPE and event.state_key is not None:
- return True # always allow policy server change events
-
policy_event = await self._storage_controllers.state.get_current_state_event(
- event.room_id, POLICY_SERVER_EVENT_TYPE, ""
+ room_id, EventTypes.RoomPolicy, ""
)
+ public_key = None
if not policy_event:
- return True # no policy server == default allow
+ # TODO: Remove unstable MSC4284 support
+ # https://github.com/element-hq/synapse/issues/19502
+ policy_event = (
+ await self._storage_controllers.state.get_current_state_event(
+ room_id, "org.matrix.msc4284.policy", ""
+ )
+ )
+ if not policy_event:
+ return None # neither stable or unstable configured
+
+ # Unstable configured, grab its public key
+ public_key = policy_event.content.get("public_key", None)
+ else:
+ # Stable configured, grab its public key
+ public_keys = policy_event.content.get("public_keys")
+ if isinstance(public_keys, dict):
+ ed25519_key = public_keys.get("ed25519")
+ if isinstance(ed25519_key, str):
+ public_key = ed25519_key
+
+ if public_key is None or not isinstance(public_key, str):
+ return None # no public key means no policy server
policy_server = policy_event.content.get("via", "")
if policy_server is None or not isinstance(policy_server, str):
- return True # no policy server == default allow
+ return None # no policy server
if policy_server == self._hs.hostname:
- return True # Synapse itself can't be a policy server (currently)
+ return None # Synapse itself can't be a policy server (currently)
try:
parse_and_validate_server_name(policy_server)
except ValueError:
- return True # invalid policy server == default allow
+ return None # invalid policy server
is_in_room = await self._event_auth_handler.is_host_in_room(
- event.room_id, policy_server
+ room_id, policy_server
)
if not is_in_room:
- return True # policy server not in room == default allow
-
- # Check if the event has been signed with the public key in the policy server state event.
- # If it is, we can save an HTTP hit.
- # We actually want to get the policy server state event BEFORE THE EVENT rather than
- # the current state value, else changing the public key will cause all of these checks to fail.
- # However, if we are checking outlier events (which we will due to is_event_allowed being called
- # near the edges at _check_sigs_and_hash) we won't know the state before the event, so the
- # only safe option is to use the current state
- public_key = policy_event.content.get("public_key", None)
- if public_key is not None and isinstance(public_key, str):
- valid = await self._verify_policy_server_signature(
- event, policy_server, public_key
- )
- if valid:
- return True
- # fallthrough to hit /check manually
-
- # At this point, the server appears valid and is in the room, so ask it to check
- # the event.
- recommendation = await self._federation_client.get_pdu_policy_recommendation(
- policy_server, event
+ return None # policy server not in room
+
+ return PolicyServerInfo(policy_server, public_key)
+
+ async def is_event_allowed(self, event: EventBase) -> bool:
+ """Check if the given event is allowed in the room by the policy server.
+
+ Note: This will *always* return True if the room's policy server is Synapse
+ itself. This is because Synapse can't be a policy server (currently).
+
+ If no policy server is configured in the room, this returns True. Similarly, if
+ the policy server is invalid in any way (not joined, not a server, etc), this
+ returns True.
+
+ If a valid and contactable policy server is configured in the room, this returns
+ True if that server suggests the event is not spammy, and False otherwise.
+
+ Args:
+ event: The event to check. This should be a fully-formed PDU.
+
+ Returns:
+ bool: True if the event is allowed in the room, False otherwise.
+ """
+ if self._is_policy_server_state_event(event):
+ return True # always allow policy server change events
+
+ policy_server = await self._get_policy_server(event.room_id)
+ if policy_server is None:
+ return True # no policy server configured, so allow
+
+ # Check if the event has been signed with the public key in the policy server
+ # state event. If it is, the event is valid according to the policy server and
+ # we don't need to request a fresh signature.
+ valid = await self._verify_policy_server_signature(
+ event, policy_server.server_name, policy_server.public_key
)
- if recommendation != RECOMMENDATION_OK:
+ if valid:
+ return True # valid signature == allow
+
+ # We couldn't save the HTTP hit, so do that hit.
+ try:
+ await self.ask_policy_server_to_sign_event(event, verify=True)
+ except Exception as ex:
+ # We probably caught either a refusal to sign, an invalid signature, or
+ # some other transient or network error. These are all rejection cases.
+ logger.warning("Failed to get a signature from the policy server: %s", ex)
return False
- return True # default allow
+ return True # passed all verifications and checks, so allow
async def _verify_policy_server_signature(
self, event: EventBase, policy_server: str, public_key: str
@@ -140,47 +203,71 @@ async def ask_policy_server_to_sign_event(
"""Ask the policy server to sign this event. The signature is added to the event signatures block.
Does nothing if there is no policy server state event in the room. If the policy server
- refuses to sign the event (as it's marked as spam) does nothing.
+ refuses to sign the event (as it's marked as spam), an error is raised.
Args:
- event: The event to sign
- verify: If True, verify that the signature is correctly signed by the public_key in the
- policy server state event.
+ event: The event to sign.
+ verify: If True, verify that the signature is correctly signed by the policy server's
+ defined public key.
Raises:
- if verify=True and the policy server signed the event with an invalid signature. Does
- not raise if the policy server refuses to sign the event.
+ When the policy server refuses to sign the event, or when verify is True and the
+ signature is invalid.
"""
- policy_event = await self._storage_controllers.state.get_current_state_event(
- event.room_id, POLICY_SERVER_EVENT_TYPE, ""
- )
- if not policy_event:
+ if self._is_policy_server_state_event(event):
+ # per spec, policy servers aren't asked to sign `m.room.policy` state events
+ # with empty state keys
return
- policy_server = policy_event.content.get("via", None)
- if policy_server is None or not isinstance(policy_server, str):
- return
- # Only ask to sign events if the policy state event has a public_key (so they can be subsequently verified)
- public_key = policy_event.content.get("public_key", None)
- if public_key is None or not isinstance(public_key, str):
+
+ policy_server = await self._get_policy_server(event.room_id)
+ if policy_server is None:
return
# Ask the policy server to sign this event.
# We set a smallish timeout here as we don't want to block event sending too long.
- signature = await self._federation_client.ask_policy_server_to_sign_event(
- policy_server,
- event,
- timeout=3000,
- )
- if (
- # the policy server returns {} if it refuses to sign the event.
- signature and len(signature) > 0
- ):
- event.signatures.update(signature)
- if verify:
- is_valid = await self._verify_policy_server_signature(
- event, policy_server, public_key
+ try:
+ signature = await self._federation_client.ask_policy_server_to_sign_event(
+ policy_server.server_name,
+ event,
+ timeout=3000,
+ )
+ # TODO: We can *probably* remove this when we remove unstable MSC4284 support.
+ # The server *should* be returning either a signature or an error, but there could
+ # also be implementation bugs. Whoever reads this when removing unstable MSC4284
+ # stuff, make a decision on whether to remove this bit.
+ # https://github.com/element-hq/synapse/issues/19502
+ if not signature or len(signature) == 0:
+ raise SynapseError(
+ 403,
+ "This event has been rejected as probable spam by the policy server",
+ Codes.FORBIDDEN,
+ )
+
+ # Note: if the policy server and event sender are the same server, the sender
+ # might not have added policy server signatures to the event for whatever reason.
+ # When this happens, we don't want to obliterate the event's existing signatures
+ # because the event will fail authorization. This is why we add defaults rather
+ # than simply `update` the signatures on the event.
+ #
+ # This situation can happen if the homeserver and policy server parts are
+ # logically the same server, but run by different software. For example, Synapse
+ # will not ask "itself" for a policy server signature, even if its server name
+ # is the designated policy server, so it could send an event outwards that other
+ # servers need to manually fetch signatures for. This is the code that allows
+ # those events to continue working (because they're legally sent, even if missing
+ # the policy server signature).
+ event.signatures.setdefault(policy_server.server_name, {}).update(
+ signature.get(policy_server.server_name, {})
+ )
+ except HttpResponseException as ex:
+ # re-wrap HTTP errors as `SynapseError` so they can be proxied to clients directly
+ raise ex.to_synapse_error() from ex
+
+ if verify:
+ is_valid = await self._verify_policy_server_signature(
+ event, policy_server.server_name, policy_server.public_key
+ )
+ if not is_valid:
+ raise SynapseError(
+ 500,
+ f"policy server {policy_server.server_name} failed to sign event correctly",
)
- if not is_valid:
- raise SynapseError(
- 500,
- f"policy server {policy_server} failed to sign event correctly",
- )
diff --git a/synapse/types/handlers/policy_server.py b/synapse/types/handlers/policy_server.py
deleted file mode 100644
index bfc09dabf4d..00000000000
--- a/synapse/types/handlers/policy_server.py
+++ /dev/null
@@ -1,16 +0,0 @@
-#
-# This file is licensed under the Affero General Public License (AGPL) version 3.
-#
-# Copyright (C) 2025 New Vector, 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:
-# .
-#
-
-RECOMMENDATION_OK = "ok"
-RECOMMENDATION_SPAM = "spam"
diff --git a/tests/handlers/test_room_policy.py b/tests/handlers/test_room_policy.py
index ff212ab06ef..9adf0e38c25 100644
--- a/tests/handlers/test_room_policy.py
+++ b/tests/handlers/test_room_policy.py
@@ -19,7 +19,8 @@
from twisted.internet.testing import MemoryReactor
-from synapse.api.errors import SynapseError
+from synapse.api.constants import EventTypes
+from synapse.api.errors import HttpResponseException, SynapseError
from synapse.crypto.event_signing import compute_event_signature
from synapse.events import EventBase, make_event_from_dict
from synapse.handlers.room_policy import POLICY_SERVER_KEY_ID
@@ -27,7 +28,6 @@
from synapse.rest.client import filter, login, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
-from synapse.types.handlers.policy_server import RECOMMENDATION_OK, RECOMMENDATION_SPAM
from synapse.util.clock import Clock
from tests import unittest
@@ -49,13 +49,9 @@ def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
# mock out the federation transport client
self.mock_federation_transport_client = mock.Mock(
spec=[
- "get_policy_recommendation_for_pdu",
"ask_policy_server_to_sign_event",
]
)
- self.mock_federation_transport_client.get_policy_recommendation_for_pdu = (
- mock.AsyncMock()
- )
self.mock_federation_transport_client.ask_policy_server_to_sign_event = (
mock.AsyncMock()
)
@@ -106,25 +102,6 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
},
)
- # Prepare the policy server mock to decide spam vs not spam on those events
- self.call_count = 0
-
- async def get_policy_recommendation_for_pdu(
- destination: str,
- pdu: EventBase,
- timeout: int | None = None,
- ) -> JsonDict:
- self.call_count += 1
- self.assertEqual(destination, self.OTHER_SERVER_NAME)
- if pdu.event_id == self.spammy_event.event_id:
- return {"recommendation": RECOMMENDATION_SPAM}
- elif pdu.event_id == self.not_spammy_event.event_id:
- return {"recommendation": RECOMMENDATION_OK}
- else:
- self.fail("Unexpected event ID")
-
- self.mock_federation_transport_client.get_policy_recommendation_for_pdu.side_effect = get_policy_recommendation_for_pdu
-
# Mock policy server actions on signing events
async def policy_server_signs_event(
destination: str, pdu: EventBase, timeout: int | None = None
@@ -157,7 +134,9 @@ async def policy_server_refuses_to_sign_event(
async def policy_server_event_sign_error(
destination: str, pdu: EventBase, timeout: int | None = None
) -> JsonDict | None:
- return None
+ raise HttpResponseException(
+ 500, "Internal Server Error", b'{"errcode": "M_UNKNOWN"}'
+ )
self.policy_server_signs_event = policy_server_signs_event
self.policy_server_refuses_to_sign_event = policy_server_refuses_to_sign_event
@@ -174,14 +153,16 @@ def _add_policy_server_to_room(self, public_key: str | None = None) -> None:
self.hs, self.room_id, policy_user_id, "join"
)
)
- content = {
+ content: JsonDict = {
"via": self.OTHER_SERVER_NAME,
}
if public_key is not None:
- content["public_key"] = public_key
+ content["public_keys"] = {
+ "ed25519": public_key,
+ }
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
content,
tok=self.creator_token,
state_key="",
@@ -192,12 +173,11 @@ def test_no_policy_event_set(self) -> None:
# case where a room doesn't use a policy server.
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
def test_empty_policy_event_set(self) -> None:
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
{
# empty content (no `via`)
},
@@ -207,12 +187,11 @@ def test_empty_policy_event_set(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
def test_nonstring_policy_event_set(self) -> None:
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
{
"via": 42, # should be a server name
},
@@ -222,12 +201,11 @@ def test_nonstring_policy_event_set(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
def test_self_policy_event_set(self) -> None:
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
{
# We ignore events when the policy server is ourselves (for now?)
"via": (UserID.from_string(self.creator)).domain,
@@ -238,12 +216,11 @@ def test_self_policy_event_set(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
def test_invalid_server_policy_event_set(self) -> None:
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
{
"via": "|this| is *not* a (valid) server name.com",
},
@@ -253,12 +230,11 @@ def test_invalid_server_policy_event_set(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
def test_not_in_room_policy_event_set(self) -> None:
self.helper.send_state(
self.room_id,
- "org.matrix.msc4284.policy",
+ EventTypes.RoomPolicy,
{
"via": f"x.{self.OTHER_SERVER_NAME}",
},
@@ -268,14 +244,30 @@ def test_not_in_room_policy_event_set(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, True)
- self.assertEqual(self.call_count, 0)
+
+ def test_missing_public_key_event_set(self) -> None:
+ """
+ Tests that a missing public key in the `m.room.policy` state event (an invalid
+ configuration) is treated as though there is no policy server configured, thus
+ allowing all events.
+ """
+ self._add_policy_server_to_room() # no public_key
+
+ ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
+ self.assertEqual(ok, True)
def test_spammy_event_is_spam(self) -> None:
- self._add_policy_server_to_room()
+ verify_key_str = encode_verify_key_base64(get_verify_key(self.signing_key))
+ self._add_policy_server_to_room(public_key=verify_key_str)
+
+ # Explicitly configure the policy server mock to refuse to sign the event.
+ self.mock_federation_transport_client.ask_policy_server_to_sign_event.return_value = False
ok = self.get_success(self.handler.is_event_allowed(self.spammy_event))
self.assertEqual(ok, False)
- self.assertEqual(self.call_count, 1)
+
+ # Ensure we actually contacted the policy server once for this event.
+ self.mock_federation_transport_client.ask_policy_server_to_sign_event.assert_awaited_once()
def test_signed_event_is_not_spam(self) -> None:
verify_key_str = encode_verify_key_base64(get_verify_key(self.signing_key))
@@ -306,8 +298,6 @@ def test_signed_event_is_not_spam(self) -> None:
ok = self.get_success(self.handler.is_event_allowed(event))
self.assertEqual(ok, True)
- # Make sure we did not make an HTTP hit to get_policy_recommendation_for_pdu
- self.assertEqual(self.call_count, 0)
def test_ask_policy_server_to_sign_event_ok(self) -> None:
verify_key_str = encode_verify_key_base64(get_verify_key(self.signing_key))
@@ -348,8 +338,15 @@ def test_ask_policy_server_to_sign_event_refuses(self) -> None:
},
)
self.mock_federation_transport_client.ask_policy_server_to_sign_event.side_effect = self.policy_server_refuses_to_sign_event
- self.get_success(
- self.handler.ask_policy_server_to_sign_event(event, verify=True)
+ fail = self.get_failure(
+ self.handler.ask_policy_server_to_sign_event(event, verify=True),
+ SynapseError,
+ )
+ self.assertIsInstance(fail.value, SynapseError)
+ self.assertEqual(fail.value.code, 403)
+ self.assertEqual(
+ fail.value.msg,
+ "This event has been rejected as probable spam by the policy server",
)
self.assertEqual(len(event.signatures), 0)
@@ -370,9 +367,12 @@ def test_ask_policy_server_to_sign_event_cannot_reach(self) -> None:
},
)
self.mock_federation_transport_client.ask_policy_server_to_sign_event.side_effect = self.policy_server_event_sign_error
- self.get_success(
- self.handler.ask_policy_server_to_sign_event(event, verify=True)
+ fail = self.get_failure(
+ self.handler.ask_policy_server_to_sign_event(event, verify=True),
+ SynapseError,
)
+ self.assertIsInstance(fail.value, SynapseError)
+ self.assertEqual(fail.value.code, 500)
self.assertEqual(len(event.signatures), 0)
def test_ask_policy_server_to_sign_event_wrong_sig(self) -> None:
From 713aa7ebf08a0a8db6caf34fa92204a1a357b5e4 Mon Sep 17 00:00:00 2001
From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:16:23 +0000
Subject: [PATCH 090/121] Hide successful, skipped Complement tests in the CI
(#19590)
Co-authored-by: Eric Eastwood
---
.ci/complement_package.gotpl | 5 ++--
.ci/scripts/gotestfmt | 21 ----------------
.ci/scripts/setup_complement_prerequisites.sh | 1 -
.github/workflows/tests.yml | 24 +++++++++++++++----
changelog.d/19590.misc | 1 +
5 files changed, 24 insertions(+), 28 deletions(-)
delete mode 100755 .ci/scripts/gotestfmt
create mode 100644 changelog.d/19590.misc
diff --git a/.ci/complement_package.gotpl b/.ci/complement_package.gotpl
index 2dd5e5e0e69..e71ed9f616b 100644
--- a/.ci/complement_package.gotpl
+++ b/.ci/complement_package.gotpl
@@ -29,7 +29,7 @@ which is under the Unlicense licence.
{{- with .TestCases -}}
{{- /* Passing tests are first */ -}}
{{- range . -}}
- {{- if eq .Result "PASS" -}}
+ {{- if and (eq .Result "PASS") (not $settings.HideSuccessfulTests) -}}
::group::{{ "\033" }}[0;32m✅{{ " " }}{{- .Name -}}
{{- "\033" -}}[0;37m ({{if $settings.ShowTestStatus}}{{.Result}}; {{end}}{{ .Duration -}}
{{- with .Coverage -}}
@@ -49,7 +49,8 @@ which is under the Unlicense licence.
{{- /* Then skipped tests are second */ -}}
{{- range . -}}
- {{- if eq .Result "SKIP" -}}
+ {{- /* Skipped tests are also purposefully hidden if "HideSuccessfulTests" is enabled */ -}}
+ {{- if and (eq .Result "SKIP") (not $settings.HideSuccessfulTests) -}}
::group::{{ "\033" }}[0;33m🚧{{ " " }}{{- .Name -}}
{{- "\033" -}}[0;37m ({{if $settings.ShowTestStatus}}{{.Result}}; {{end}}{{ .Duration -}}
{{- with .Coverage -}}
diff --git a/.ci/scripts/gotestfmt b/.ci/scripts/gotestfmt
deleted file mode 100755
index 83e0ec63613..00000000000
--- a/.ci/scripts/gotestfmt
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash
-#
-# wraps `gotestfmt`, hiding output from successful packages unless
-# all tests passed.
-
-set -o pipefail
-set -e
-
-# tee the test results to a log, whilst also piping them into gotestfmt,
-# telling it to hide successful results, so that we can clearly see
-# unsuccessful results.
-tee complement.log | gotestfmt -hide successful-packages
-
-# gotestfmt will exit non-zero if there were any failures, so if we got to this
-# point, we must have had a successful result.
-echo "All tests successful; showing all test results"
-
-# Pipe the test results back through gotestfmt, showing all results.
-# The log file consists of JSON lines giving the test results, interspersed
-# with regular stdout lines (including reports of downloaded packages).
-grep '^{"Time":' complement.log | gotestfmt
diff --git a/.ci/scripts/setup_complement_prerequisites.sh b/.ci/scripts/setup_complement_prerequisites.sh
index 47a3ff8e696..1fdaf5e631a 100755
--- a/.ci/scripts/setup_complement_prerequisites.sh
+++ b/.ci/scripts/setup_complement_prerequisites.sh
@@ -10,7 +10,6 @@ alias block='{ set +x; } 2>/dev/null; func() { echo "::group::$*"; set -x; }; fu
alias endblock='{ set +x; } 2>/dev/null; func() { echo "::endgroup::"; set -x; }; func'
block Install Complement Dependencies
- sudo apt-get -qq update && sudo apt-get install -qqy libolm3 libolm-dev
go install -v github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest
endblock
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 35c633b8edf..c3dd8b14e56 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -744,6 +744,12 @@ jobs:
- name: Formatted sanity check Complement test logs
# Always run this step if we attempted to run the Complement tests.
if: always() && steps.run_sanity_check_complement_image_test.outcome != 'skipped'
+ # We do not hide successful tests in `gotestfmt` here as the list of sanity
+ # check tests is so short. Feel free to change this when we get more tests.
+ #
+ # Note that the `-hide` argument is interpreted by `gotestfmt`. From it,
+ # it derives several values under `$settings` and passes them to our
+ # custom `.ci/complement_package.gotpl` template to render the output.
run: cat /tmp/gotest-sanity-check-complement.log | gotestfmt -hide "successful-downloads,empty-packages"
- name: Run Complement Tests
@@ -764,10 +770,15 @@ jobs:
POSTGRES: ${{ (matrix.database == 'Postgres') && 1 || '' }}
WORKERS: ${{ (matrix.arrangement == 'workers') && 1 || '' }}
- - name: Formatted Complement test logs
+ - name: Formatted Complement test logs (only failing are shown)
# Always run this step if we attempted to run the Complement tests.
if: always() && steps.run_complement_tests.outcome != 'skipped'
- run: cat /tmp/gotest-complement.log | gotestfmt -hide "successful-downloads,empty-packages"
+ # Hide successful tests in order to reduce the verbosity of the otherwise very large output.
+ #
+ # Note that the `-hide` argument is interpreted by `gotestfmt`. From it,
+ # it derives several values under `$settings` and passes them to our
+ # custom `.ci/complement_package.gotpl` template to render the output.
+ run: cat /tmp/gotest-complement.log | gotestfmt -hide "successful-downloads,successful-tests,empty-packages"
- name: Run in-repo Complement Tests
id: run_in_repo_complement_tests
@@ -787,10 +798,15 @@ jobs:
POSTGRES: ${{ (matrix.database == 'Postgres') && 1 || '' }}
WORKERS: ${{ (matrix.arrangement == 'workers') && 1 || '' }}
- - name: Formatted in-repo Complement test logs
+ - name: Formatted in-repo Complement test logs (only failing are shown)
# Always run this step if we attempted to run the Complement tests.
if: always() && steps.run_in_repo_complement_tests.outcome != 'skipped'
- run: cat /tmp/gotest-in-repo-complement.log | gotestfmt -hide "successful-downloads,empty-packages"
+ # Hide successful tests in order to reduce the verbosity of the otherwise very large output.
+ #
+ # Note that the `-hide` argument is interpreted by `gotestfmt`. From it,
+ # it derives several values under `$settings` and passes them to our
+ # custom `.ci/complement_package.gotpl` template to render the output.
+ run: cat /tmp/gotest-in-repo-complement.log | gotestfmt -hide "successful-downloads,successful-tests,empty-packages"
cargo-test:
if: ${{ needs.changes.outputs.rust == 'true' }}
diff --git a/changelog.d/19590.misc b/changelog.d/19590.misc
new file mode 100644
index 00000000000..b5357334ceb
--- /dev/null
+++ b/changelog.d/19590.misc
@@ -0,0 +1 @@
+Only show failing Complement tests in the formatted output in CI.
\ No newline at end of file
From 963fc9e55ad2d8786e67a6d4a2407f806109186d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:16:28 +0000
Subject: [PATCH 091/121] Bump docker/metadata-action from 5.10.0 to 6.0.0
(#19600)
---
.github/workflows/docker.yml | 2 +-
.github/workflows/push_complement_image.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index a796f9cf2a8..a54efb89371 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -189,7 +189,7 @@ jobs:
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Calculate docker image tag
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.repository }}
flavor: |
diff --git a/.github/workflows/push_complement_image.yml b/.github/workflows/push_complement_image.yml
index 12599457dc7..e0384e7091d 100644
--- a/.github/workflows/push_complement_image.yml
+++ b/.github/workflows/push_complement_image.yml
@@ -59,7 +59,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Work out labels for complement image
id: meta
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}/complement-synapse
tags: |
From e0b08da42ee0079c03da55eb580d548a419cbe32 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:16:50 +0000
Subject: [PATCH 092/121] Bump docker/build-push-action from 6.19.2 to 7.0.0
(#19599)
---
.github/workflows/docker.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index a54efb89371..5ccc6494039 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -87,7 +87,7 @@ jobs:
- name: Build and push by digest
id: build
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
push: true
labels: |
From 71092aa58474f3a0488324cb50470c83bc41b21e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:17:14 +0000
Subject: [PATCH 093/121] Bump docker/setup-buildx-action from 3.12.0 to 4.0.0
(#19598)
---
.github/workflows/docker.yml | 4 ++--
.github/workflows/release-artifacts.yml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 5ccc6494039..3baad943844 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -183,7 +183,7 @@ jobs:
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml
index 125d659f4e1..95d3d98d75b 100644
--- a/.github/workflows/release-artifacts.yml
+++ b/.github/workflows/release-artifacts.yml
@@ -61,7 +61,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Set up docker layer caching
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
From 57ef7b91920f998d897422680c220b00d5a3fa7e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:17:30 +0000
Subject: [PATCH 094/121] Bump docker/login-action from 3.7.0 to 4.0.0 (#19597)
---
.github/workflows/docker.yml | 12 ++++++------
.github/workflows/push_complement_image.yml | 2 +-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 3baad943844..d581b5ec566 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -41,13 +41,13 @@ jobs:
echo "SYNAPSE_VERSION=$(grep "^version" pyproject.toml | sed -E 's/version\s*=\s*["]([^"]*)["]/\1/')" >> $GITHUB_ENV
- name: Log in to DockerHub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -79,7 +79,7 @@ jobs:
services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to Element OCI Registry
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
@@ -136,14 +136,14 @@ jobs:
merge-multiple: true
- name: Log in to DockerHub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: ${{ startsWith(matrix.repository, 'docker.io') }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: ${{ startsWith(matrix.repository, 'ghcr.io') }}
with:
registry: ghcr.io
@@ -176,7 +176,7 @@ jobs:
services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to Element OCI Registry
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
diff --git a/.github/workflows/push_complement_image.yml b/.github/workflows/push_complement_image.yml
index e0384e7091d..3931dbebb32 100644
--- a/.github/workflows/push_complement_image.yml
+++ b/.github/workflows/push_complement_image.yml
@@ -52,7 +52,7 @@ jobs:
with:
poetry-version: "2.2.1"
- name: Login to registry
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
From 4b19411445592f9ce109d35e2420f2a1f35610b5 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 10:20:24 +0000
Subject: [PATCH 095/121] Bump rustls-webpki from 0.103.4 to 0.103.10 (#19594)
---
Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index d6945bfbb79..c15688814ca 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1116,9 +1116,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.4"
+version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
+checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
From 33d47f43e441ef06ac5a855d383dc41df4f4dbdb Mon Sep 17 00:00:00 2001
From: Quentin Gliech
Date: Tue, 24 Mar 2026 15:17:23 +0100
Subject: [PATCH 096/121] 1.150.0
---
CHANGES.md | 7 +++++++
debian/changelog | 6 ++++++
pyproject.toml | 2 +-
3 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/CHANGES.md b/CHANGES.md
index e7c24162385..6dbee41e7cc 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,10 @@
+# Synapse 1.150.0 (2026-03-24)
+
+No significant changes since 1.150.0rc1.
+
+
+
+
# Synapse 1.150.0rc1 (2026-03-17)
## Features
diff --git a/debian/changelog b/debian/changelog
index 55af4c22b55..c8146764de1 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+matrix-synapse-py3 (1.150.0) stable; urgency=medium
+
+ * New synapse release 1.150.0.
+
+ -- Synapse Packaging team Tue, 24 Mar 2026 14:17:04 +0000
+
matrix-synapse-py3 (1.150.0~rc1) stable; urgency=medium
[ Quentin Gliech ]
diff --git a/pyproject.toml b/pyproject.toml
index adb9993aae8..c2f01826876 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
-version = "1.150.0rc1"
+version = "1.150.0"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
From 7fad50fd76fe1d6da6fa9154cdbb9e5e6acb7c4a Mon Sep 17 00:00:00 2001
From: Mathieu Velten
Date: Tue, 24 Mar 2026 17:22:11 +0100
Subject: [PATCH 097/121] Limit outgoing to_device EDU size to 65536 (#18416)
If a set of messages exceeds this limit, the messages are split
across several EDUs.
Fix #17035 (should)
There is currently [no official specced limit for
EDUs](https://github.com/matrix-org/matrix-spec/issues/807), but the
consensus seems to be that it would be useful to have one to avoid this
bug by bounding the transaction size.
As a side effect it also limits the size of a single to-device message
to a bit less than 65536.
This should probably be added to the spec similarly to the [message size
limit.](https://spec.matrix.org/v1.14/client-server-api/#size-limits)
Spec PR: https://github.com/matrix-org/matrix-spec/pull/2340
---------
Co-authored-by: mcalinghee
Co-authored-by: Eric Eastwood
---
changelog.d/18416.bugfix | 1 +
synapse/api/constants.py | 16 ++
.../sender/per_destination_queue.py | 13 +-
synapse/handlers/devicemessage.py | 158 ++++++++++++++---
synapse/storage/databases/main/deviceinbox.py | 47 +++++-
tests/handlers/test_appservice.py | 4 +-
tests/rest/client/test_sendtodevice.py | 159 +++++++++++++++++-
7 files changed, 360 insertions(+), 38 deletions(-)
create mode 100644 changelog.d/18416.bugfix
diff --git a/changelog.d/18416.bugfix b/changelog.d/18416.bugfix
new file mode 100644
index 00000000000..71f181fed40
--- /dev/null
+++ b/changelog.d/18416.bugfix
@@ -0,0 +1 @@
+A long queue of to-device messages could prevent outgoing federation because of the size of the transaction, let's limit the to-device EDU size to a reasonable value.
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index eb9e6cc39b9..27f06ec525b 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -30,6 +30,22 @@
# the max size of a (canonical-json-encoded) event
MAX_PDU_SIZE = 65536
+# This isn't spec'ed but is our own reasonable default to play nice with Synapse's
+# `max_request_size`/`max_request_body_size`. We chose the same as `MAX_PDU_SIZE` as our
+# `max_request_body_size` math is currently limited by 200 `MAX_PDU_SIZE` things. The
+# spec for a `/federation/v1/send` request sets the limit at 100 EDU's and 50 PDU's
+# which is below that 200 `MAX_PDU_SIZE` limit (`max_request_body_size`).
+#
+# Allowing oversized EDU's results in failed `/federation/v1/send` transactions (because
+# the request overall can overrun the `max_request_body_size`) which are retried over
+# and over and prevent other outbound federation traffic from happening.
+MAX_EDU_SIZE = 65536
+
+# This is defined in the Matrix spec and enforced by the receiver.
+MAX_EDUS_PER_TRANSACTION = 100
+# A transaction can contain up to 100 EDUs but synapse reserves 10 EDUs for other purposes
+# like trickling out some device list updates.
+NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION = 10
# The maximum allowed size of an HTTP request.
# Other than media uploads, the biggest request we expect to see is a fully-loaded
diff --git a/synapse/federation/sender/per_destination_queue.py b/synapse/federation/sender/per_destination_queue.py
index cdacf16d725..32f8630c9d1 100644
--- a/synapse/federation/sender/per_destination_queue.py
+++ b/synapse/federation/sender/per_destination_queue.py
@@ -30,7 +30,11 @@
from twisted.internet import defer
-from synapse.api.constants import EduTypes
+from synapse.api.constants import (
+ MAX_EDUS_PER_TRANSACTION,
+ NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION,
+ EduTypes,
+)
from synapse.api.errors import (
FederationDeniedError,
HttpResponseException,
@@ -51,9 +55,6 @@
if TYPE_CHECKING:
import synapse.server
-# This is defined in the Matrix spec and enforced by the receiver.
-MAX_EDUS_PER_TRANSACTION = 100
-
logger = logging.getLogger(__name__)
@@ -798,7 +799,9 @@ async def __aenter__(self) -> tuple[list[EventBase], list[Edu]]:
(
to_device_edus,
device_stream_id,
- ) = await self.queue._get_to_device_message_edus(edu_limit - 10)
+ ) = await self.queue._get_to_device_message_edus(
+ edu_limit - NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION
+ )
if to_device_edus:
self._device_stream_id = device_stream_id
diff --git a/synapse/handlers/devicemessage.py b/synapse/handlers/devicemessage.py
index 0ef14b31dad..2096b44f6ca 100644
--- a/synapse/handlers/devicemessage.py
+++ b/synapse/handlers/devicemessage.py
@@ -23,8 +23,15 @@
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
-from synapse.api.constants import EduTypes, EventContentFields, ToDeviceEventTypes
-from synapse.api.errors import Codes, SynapseError
+from canonicaljson import encode_canonical_json
+
+from synapse.api.constants import (
+ MAX_EDU_SIZE,
+ EduTypes,
+ EventContentFields,
+ ToDeviceEventTypes,
+)
+from synapse.api.errors import Codes, EventSizeError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.logging.context import run_in_background
from synapse.logging.opentracing import (
@@ -35,7 +42,7 @@
)
from synapse.types import JsonDict, Requester, StreamKeyType, UserID, get_domain_from_id
from synapse.util.json import json_encoder
-from synapse.util.stringutils import random_string
+from synapse.util.stringutils import random_string_insecure_fast
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -222,6 +229,7 @@ async def send_device_message(
set_tag(SynapseTags.TO_DEVICE_TYPE, message_type)
set_tag(SynapseTags.TO_DEVICE_SENDER, sender_user_id)
local_messages = {}
+ # Map from destination (server) -> recipient (user ID) -> device_id -> JSON message content
remote_messages: dict[str, dict[str, dict[str, JsonDict]]] = {}
for user_id, by_device in messages.items():
if not UserID.is_valid(user_id):
@@ -277,28 +285,33 @@ async def send_device_message(
destination = get_domain_from_id(user_id)
remote_messages.setdefault(destination, {})[user_id] = by_device
- context = get_active_span_text_map()
-
- remote_edu_contents = {}
- for destination, messages in remote_messages.items():
- # The EDU contains a "message_id" property which is used for
- # idempotence. Make up a random one.
- message_id = random_string(16)
- log_kv({"destination": destination, "message_id": message_id})
-
- remote_edu_contents[destination] = {
- "messages": messages,
- "sender": sender_user_id,
- "type": message_type,
- "message_id": message_id,
- "org.matrix.opentracing_context": json_encoder.encode(context),
- }
-
- # Add messages to the database.
+ # Add local messages to the database.
# Retrieve the stream id of the last-processed to-device message.
- last_stream_id = await self.store.add_messages_to_device_inbox(
- local_messages, remote_edu_contents
+ last_stream_id = (
+ await self.store.add_local_messages_from_client_to_device_inbox(
+ local_messages
+ )
)
+ for destination, messages in remote_messages.items():
+ split_edus = split_device_messages_into_edus(
+ sender_user_id, message_type, messages
+ )
+ for edu in split_edus:
+ edu["org.matrix.opentracing_context"] = json_encoder.encode(
+ get_active_span_text_map()
+ )
+ # Add remote messages to the database.
+ last_stream_id = (
+ await self.store.add_remote_messages_from_client_to_device_inbox(
+ {destination: edu}
+ )
+ )
+ log_kv(
+ {
+ "destination": destination,
+ "message_id": edu["message_id"],
+ }
+ )
# Notify listeners that there are new to-device messages to process,
# handing them the latest stream id.
@@ -397,3 +410,102 @@ async def get_events_for_dehydrated_device(
"events": messages,
"next_batch": f"d{stream_id}",
}
+
+
+def split_device_messages_into_edus(
+ sender_user_id: str,
+ message_type: str,
+ messages_by_user_then_device: dict[str, dict[str, JsonDict]],
+) -> list[JsonDict]:
+ """
+ This function takes many to-device messages and fits/splits them into several EDUs
+ as necessary. We split the messages up as the overall request can overrun the
+ `max_request_body_size` and prevent outbound federation traffic because of the size
+ of the transaction (cf. `MAX_EDU_SIZE`).
+
+ Args:
+ sender_user_id: The user that is sending the to-device messages.
+ message_type: The type of to-device messages that are being sent.
+ messages_by_user_then_device: Dictionary of recipient user_id to recipient device_id to message.
+
+ Returns:
+ Bin-packed list of EDU JSON content for the given to_device messages
+
+ Raises:
+ EventSizeError: If a single to-device message is too large to fit into an EDU.
+ """
+ split_edus_content: list[JsonDict] = []
+
+ # Convert messages dict to a list of (recipient, messages_by_device) pairs
+ message_items = list(messages_by_user_then_device.items())
+
+ while message_items:
+ edu_messages = {}
+ # Start by trying to fit all remaining messages
+ target_count = len(message_items)
+
+ while target_count > 0:
+ # Take the first target_count messages
+ edu_messages = dict(message_items[:target_count])
+ edu_content = create_new_to_device_edu_content(
+ sender_user_id, message_type, edu_messages
+ )
+ # Let's add the whole EDU structure before testing the size
+ edu = {
+ "content": edu_content,
+ "edu_type": EduTypes.DIRECT_TO_DEVICE,
+ }
+
+ if len(encode_canonical_json(edu)) <= MAX_EDU_SIZE:
+ # It fits! Add this EDU and remove these messages from the list
+ split_edus_content.append(edu_content)
+ message_items = message_items[target_count:]
+
+ logger.debug(
+ "Created EDU with %d recipients from %s (message_id=%s), (total EDUs so far: %d)",
+ target_count,
+ sender_user_id,
+ edu_content["message_id"],
+ len(split_edus_content),
+ )
+ break
+ else:
+ if target_count == 1:
+ # Single recipient's messages are too large, let's reject the client
+ # call with 413/`M_TOO_LARGE`, we expect this error to reach the
+ # client in the case of the /sendToDevice endpoint.
+ #
+ # 413 is currently an unspecced response for `/sendToDevice` but is
+ # probably the best thing we can do.
+ # https://github.com/matrix-org/matrix-spec/pull/2340 tracks adding
+ # this to the spec
+ recipient = message_items[0][0]
+ raise EventSizeError(
+ f"device message to {recipient} too large to fit in a single EDU",
+ unpersistable=True,
+ )
+
+ # Halve the number of messages and try again
+ target_count = target_count // 2
+
+ return split_edus_content
+
+
+def create_new_to_device_edu_content(
+ sender_user_id: str,
+ message_type: str,
+ messages_by_user_then_device: dict[str, dict[str, JsonDict]],
+) -> JsonDict:
+ """
+ Create a new `m.direct_to_device` EDU `content` object with a unique message ID.
+ """
+ # The EDU contains a "message_id" property which is used for
+ # idempotence. Make up a random one.
+ message_id = random_string_insecure_fast(16)
+ content = {
+ "sender": sender_user_id,
+ "type": message_type,
+ "message_id": message_id,
+ "messages": messages_by_user_then_device,
+ }
+ return content
diff --git a/synapse/storage/databases/main/deviceinbox.py b/synapse/storage/databases/main/deviceinbox.py
index fc61f46c1c5..9fed22c1b3f 100644
--- a/synapse/storage/databases/main/deviceinbox.py
+++ b/synapse/storage/databases/main/deviceinbox.py
@@ -739,18 +739,15 @@ def get_all_new_device_messages_txn(
)
@trace
- async def add_messages_to_device_inbox(
+ async def add_local_messages_from_client_to_device_inbox(
self,
local_messages_by_user_then_device: dict[str, dict[str, JsonDict]],
- remote_messages_by_destination: dict[str, JsonDict],
) -> int:
- """Used to send messages from this server.
+ """Queue local device messages that will be sent to devices of local users.
Args:
local_messages_by_user_then_device:
Dictionary of recipient user_id to recipient device_id to message.
- remote_messages_by_destination:
- Dictionary of destination server_name to the EDU JSON to send.
Returns:
The new stream_id.
@@ -766,6 +763,39 @@ def add_messages_txn(
txn, stream_id, local_messages_by_user_then_device
)
+ async with self._to_device_msg_id_gen.get_next() as stream_id:
+ now_ms = self.clock.time_msec()
+ await self.db_pool.runInteraction(
+ "add_local_messages_from_client_to_device_inbox",
+ add_messages_txn,
+ now_ms,
+ stream_id,
+ )
+ for user_id in local_messages_by_user_then_device.keys():
+ self._device_inbox_stream_cache.entity_has_changed(user_id, stream_id)
+
+ return self._to_device_msg_id_gen.get_current_token()
+
+ @trace
+ async def add_remote_messages_from_client_to_device_inbox(
+ self,
+ remote_messages_by_destination: dict[str, JsonDict],
+ ) -> int:
+ """Queue device messages that will be sent to remote servers.
+
+ Args:
+ remote_messages_by_destination:
+ Dictionary of destination server_name to the EDU JSON to send.
+
+ Returns:
+ The new stream_id.
+ """
+
+ assert self._can_write_to_device
+
+ def add_messages_txn(
+ txn: LoggingTransaction, now_ms: int, stream_id: int
+ ) -> None:
# Add the remote messages to the federation outbox.
# We'll send them to a remote server when we next send a
# federation transaction to that destination.
@@ -824,10 +854,11 @@ def add_messages_txn(
async with self._to_device_msg_id_gen.get_next() as stream_id:
now_ms = self.clock.time_msec()
await self.db_pool.runInteraction(
- "add_messages_to_device_inbox", add_messages_txn, now_ms, stream_id
+ "add_remote_messages_from_client_to_device_inbox",
+ add_messages_txn,
+ now_ms,
+ stream_id,
)
- for user_id in local_messages_by_user_then_device.keys():
- self._device_inbox_stream_cache.entity_has_changed(user_id, stream_id)
for destination in remote_messages_by_destination.keys():
self._device_federation_outbox_stream_cache.entity_has_changed(
destination, stream_id
diff --git a/tests/handlers/test_appservice.py b/tests/handlers/test_appservice.py
index 6336edb108a..ac7411291bc 100644
--- a/tests/handlers/test_appservice.py
+++ b/tests/handlers/test_appservice.py
@@ -856,7 +856,9 @@ def test_application_services_receive_bursts_of_to_device(self) -> None:
# Seed the device_inbox table with our fake messages
self.get_success(
- self.hs.get_datastores().main.add_messages_to_device_inbox(messages, {})
+ self.hs.get_datastores().main.add_local_messages_from_client_to_device_inbox(
+ messages
+ )
)
# Now have local_user send a final to-device message to exclusive_as_user. All unsent
diff --git a/tests/rest/client/test_sendtodevice.py b/tests/rest/client/test_sendtodevice.py
index 56533d85f58..526d9f0b3c4 100644
--- a/tests/rest/client/test_sendtodevice.py
+++ b/tests/rest/client/test_sendtodevice.py
@@ -18,9 +18,21 @@
# [This file includes modifications made by New Vector Limited]
#
#
-from synapse.api.constants import EduTypes
+from unittest.mock import AsyncMock, Mock
+
+from twisted.test.proto_helpers import MemoryReactor
+
+from synapse.api.constants import (
+ MAX_EDU_SIZE,
+ MAX_EDUS_PER_TRANSACTION,
+ EduTypes,
+)
+from synapse.api.errors import Codes
from synapse.rest import admin
from synapse.rest.client import login, sendtodevice, sync
+from synapse.server import HomeServer
+from synapse.types import JsonDict
+from synapse.util.clock import Clock
from tests.unittest import HomeserverTestCase, override_config
@@ -41,6 +53,20 @@ class SendToDeviceTestCase(HomeserverTestCase):
sync.register_servlets,
]
+ def default_config(self) -> JsonDict:
+ config = super().default_config()
+ config["federation_sender_instances"] = None
+ return config
+
+ def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
+ self.federation_transport_client = Mock(spec=["send_transaction"])
+ self.federation_transport_client.send_transaction = AsyncMock()
+ hs = self.setup_test_homeserver(
+ federation_transport_client=self.federation_transport_client,
+ )
+
+ return hs
+
def test_user_to_user(self) -> None:
"""A to-device message from one user to another should get delivered"""
@@ -91,6 +117,137 @@ def test_user_to_user(self) -> None:
self.assertEqual(channel.code, 200, channel.result)
self.assertEqual(channel.json_body.get("to_device", {}).get("events", []), [])
+ def test_large_remote_todevice(self) -> None:
+ """A to-device message needs to fit in the EDU size limit"""
+ _ = self.register_user("u1", "pass")
+ user1_tok = self.login("u1", "pass", "d1")
+
+ # Create a message that is over the `MAX_EDU_SIZE`
+ test_msg = {"foo": "a" * MAX_EDU_SIZE}
+ channel = self.make_request(
+ "PUT",
+ "/_matrix/client/r0/sendToDevice/m.test/12345",
+ content={"messages": {"@remote_user:secondserver": {"device": test_msg}}},
+ access_token=user1_tok,
+ )
+ self.assertEqual(channel.code, 413, channel.result)
+ self.assertEqual(Codes.TOO_LARGE, channel.json_body["errcode"])
+
+ def test_edu_large_messages_splitting(self) -> None:
+ """
+ Test that a bunch of to-device messages are split over multiple EDUs if they are
+ collectively too large to fit into a single EDU
+ """
+ mock_send_transaction: AsyncMock = (
+ self.federation_transport_client.send_transaction
+ )
+ mock_send_transaction.return_value = {}
+
+ sender = self.hs.get_federation_sender()
+
+ _ = self.register_user("u1", "pass")
+ user1_tok = self.login("u1", "pass", "d1")
+ destination = "secondserver"
+ messages = {}
+
+ # 2 messages, each just big enough to fit into their own EDU
+ for i in range(2):
+ messages[f"@remote_user{i}:" + destination] = {
+ "device": {"foo": "a" * (MAX_EDU_SIZE - 1000)}
+ }
+
+ channel = self.make_request(
+ "PUT",
+ "/_matrix/client/r0/sendToDevice/m.test/1234567",
+ content={"messages": messages},
+ access_token=user1_tok,
+ )
+ self.assertEqual(channel.code, 200, channel.result)
+
+ self.get_success(sender.send_device_messages([destination]))
+
+ number_of_edus_sent = 0
+ for call in mock_send_transaction.call_args_list:
+ number_of_edus_sent += len(call[0][1]()["edus"])
+
+ self.assertEqual(number_of_edus_sent, 2)
+
+ def test_edu_small_messages_not_splitting(self) -> None:
+ """
+ Test that a couple of small messages do not get split into multiple EDUs
+ """
+ mock_send_transaction: AsyncMock = (
+ self.federation_transport_client.send_transaction
+ )
+ mock_send_transaction.return_value = {}
+
+ sender = self.hs.get_federation_sender()
+
+ _ = self.register_user("u1", "pass")
+ user1_tok = self.login("u1", "pass", "d1")
+ destination = "secondserver"
+ messages = {}
+
+ # 2 small messages that should fit in a single EDU
+ for i in range(2):
+ messages[f"@remote_user{i}:" + destination] = {"device": {"foo": "a" * 100}}
+
+ channel = self.make_request(
+ "PUT",
+ "/_matrix/client/r0/sendToDevice/m.test/123456",
+ content={"messages": messages},
+ access_token=user1_tok,
+ )
+ self.assertEqual(channel.code, 200, channel.result)
+
+ self.get_success(sender.send_device_messages([destination]))
+
+ number_of_edus_sent = 0
+ for call in mock_send_transaction.call_args_list:
+ number_of_edus_sent += len(call[0][1]()["edus"])
+
+ self.assertEqual(number_of_edus_sent, 1)
+
+ def test_transaction_splitting(self) -> None:
+ """Test that a bunch of to-device messages are split into multiple transactions if there are too many EDUs"""
+ mock_send_transaction: AsyncMock = (
+ self.federation_transport_client.send_transaction
+ )
+ mock_send_transaction.return_value = {}
+
+ sender = self.hs.get_federation_sender()
+
+ _ = self.register_user("u1", "pass")
+ user1_tok = self.login("u1", "pass", "d1")
+ destination = "secondserver"
+ messages = {}
+
+ number_of_edus_to_send = MAX_EDUS_PER_TRANSACTION + 1
+
+ for i in range(number_of_edus_to_send):
+ messages[f"@remote_user{i}:" + destination] = {
+ "device": {"foo": "a" * (MAX_EDU_SIZE - 1000)}
+ }
+
+ channel = self.make_request(
+ "PUT",
+ "/_matrix/client/r0/sendToDevice/m.test/12345678",
+ content={"messages": messages},
+ access_token=user1_tok,
+ )
+ self.assertEqual(channel.code, 200, channel.result)
+
+ self.get_success(sender.send_device_messages([destination]))
+
+ # At least 2 transactions should be sent since we are over the EDU limit per transaction
+ self.assertGreaterEqual(mock_send_transaction.call_count, 2)
+
+ number_of_edus_sent = 0
+ for call in mock_send_transaction.call_args_list:
+ number_of_edus_sent += len(call[0][1]()["edus"])
+
+ self.assertEqual(number_of_edus_sent, number_of_edus_to_send)
+
@override_config({"rc_key_requests": {"per_second": 10, "burst_count": 2}})
def test_local_room_key_request(self) -> None:
"""m.room_key_request has special-casing; test from local user"""
From 6c7e05fe206d8b9f5738bb4fc5712d1725651c59 Mon Sep 17 00:00:00 2001
From: Olivier 'reivilibre
Date: Tue, 24 Mar 2026 17:39:21 +0000
Subject: [PATCH 098/121] Allow Synapse to start up even when discovery fails
for an OpenID Connect provider. (#19509)
Fixes: #8088
Previously we would perform OIDC discovery on startup,
which involves making HTTP requests to the identity provider(s).
If that took a long time, we would block startup.
If that failed, we would crash startup.
This commit:
- makes the loading happen in the background on startup
- makes an error in the 'preload' non-fatal (though it logs at CRITICAL
for visibility)
- adds a templated error page to show on failed redirects (for
unavailable providers), as otherwise you get a JSON response in your
navigator.
- This involves introducing 2 new exception types to mark other
exceptions and keep the error handling fine-grained.
The machinery was already there to load-on-demand the discovery config,
so when the identity provider
comes back up, the discovery is reattempted and login can succeed.
Signed-off-by: Olivier 'reivilibre
---
changelog.d/19509.bugfix | 1 +
complement/go.mod | 2 +-
complement/tests/internal/dockerutil/files.go | 54 ++++++++
complement/tests/oidc_test.go | 117 ++++++++++++++++++
.../complement/conf/start_for_complement.sh | 4 +
.../conf-workers/synapse.supervisord.conf.j2 | 9 ++
docker/configure_workers_and_start.py | 7 ++
synapse/app/_base.py | 8 +-
synapse/handlers/oidc.py | 94 +++++++++++---
synapse/handlers/sso.py | 9 ++
synapse/res/templates/sso_error.html | 21 ++++
synapse/rest/client/login.py | 28 +++--
tests/handlers/test_oidc.py | 62 +++++++---
tests/server.py | 10 ++
14 files changed, 379 insertions(+), 47 deletions(-)
create mode 100644 changelog.d/19509.bugfix
create mode 100644 complement/tests/internal/dockerutil/files.go
create mode 100644 complement/tests/oidc_test.go
diff --git a/changelog.d/19509.bugfix b/changelog.d/19509.bugfix
new file mode 100644
index 00000000000..4c98f1f4ee9
--- /dev/null
+++ b/changelog.d/19509.bugfix
@@ -0,0 +1 @@
+Allow Synapse to start up even when discovery fails for an OpenID Connect provider.
\ No newline at end of file
diff --git a/complement/go.mod b/complement/go.mod
index 2a9adb9b955..716aabdf321 100644
--- a/complement/go.mod
+++ b/complement/go.mod
@@ -10,7 +10,7 @@ require (
)
require (
- github.com/docker/docker v28.3.3+incompatible // indirect
+ github.com/docker/docker v28.3.3+incompatible
github.com/docker/go-connections v0.5.0 // indirect
github.com/hashicorp/go-set/v3 v3.0.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
diff --git a/complement/tests/internal/dockerutil/files.go b/complement/tests/internal/dockerutil/files.go
new file mode 100644
index 00000000000..62d2c557afb
--- /dev/null
+++ b/complement/tests/internal/dockerutil/files.go
@@ -0,0 +1,54 @@
+package dockerutil
+
+import (
+ "archive/tar"
+ "bytes"
+ "fmt"
+ "testing"
+
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/docker/client"
+)
+
+// Write `data` as a file into a container at the given `path`.
+//
+// Internally, produces an uncompressed single-file tape archive (tar) that is sent to the docker
+// daemon to be unpacked into the container filesystem.
+func WriteFileIntoContainer(t *testing.T, docker *client.Client, containerID string, path string, data []byte) error {
+ // Create a fake/virtual tar file in memory that we can copy to the container
+ // via https://stackoverflow.com/a/52131297/796832
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ err := tw.WriteHeader(&tar.Header{
+ Name: path,
+ Mode: 0777,
+ Size: int64(len(data)),
+ })
+ if err != nil {
+ return fmt.Errorf("WriteIntoContainer: failed to write tarball header for %s: %v", path, err)
+ }
+ _, err = tw.Write([]byte(data))
+ if err != nil {
+ return fmt.Errorf("WriteIntoContainer: failed to write tarball data for %s: %w", path, err)
+ }
+
+ err = tw.Close()
+ if err != nil {
+ return fmt.Errorf("WriteIntoContainer: failed to close tarball writer for %s: %w", path, err)
+ }
+
+ // Put our new fake file in the container volume
+ err = docker.CopyToContainer(
+ t.Context(),
+ containerID,
+ "/",
+ &buf,
+ container.CopyToContainerOptions{
+ AllowOverwriteDirWithFile: false,
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("WriteIntoContainer: failed to copy: %s", err)
+ }
+ return nil
+}
diff --git a/complement/tests/oidc_test.go b/complement/tests/oidc_test.go
new file mode 100644
index 00000000000..bddb382058b
--- /dev/null
+++ b/complement/tests/oidc_test.go
@@ -0,0 +1,117 @@
+// This file is licensed under the Affero General Public License (AGPL) version 3.
+//
+// Copyright (C) 2026 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:
+// .
+
+package synapse_tests
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ dockerClient "github.com/docker/docker/client"
+ "github.com/element-hq/synapse/tests/internal/dockerutil"
+ "github.com/matrix-org/complement"
+ "github.com/matrix-org/complement/client"
+ "github.com/matrix-org/complement/match"
+ "github.com/matrix-org/complement/must"
+)
+
+const OIDC_HOMESERVER_CONFIG string = `
+oidc_providers:
+ - idp_id: "test_provider"
+ idp_name: "Test OIDC Provider"
+ issuer: "https://example.invalid"
+ client_id: "test_client_id"
+ client_secret: "test_secret"
+ scopes: ["openid"]
+ discover: true
+ user_mapping_provider:
+ module: "synapse.handlers.oidc.JinjaOidcMappingProvider"
+ config:
+ display_name_template: "{{ user.given_name }}"
+ email_template: "{{ user.email }}"
+`
+
+// Test that Synapse still starts up when configured with an OIDC provider that is unavailable.
+//
+// This is a regression test: Synapse previously would fail to start up
+// at all if the OIDC provider was down on startup.
+// https://github.com/element-hq/synapse/issues/8088
+//
+// Now instead of failing to start, Synapse will produce a 503 response on the
+// `/_matrix/client/v3/login/sso/redirect/oidc-test_provider` endpoint.
+func TestOIDCProviderUnavailable(t *testing.T) {
+ // Deploy a single homeserver
+ deployment := complement.Deploy(t, 1)
+ defer deployment.Destroy(t)
+
+ // Get Docker client to manipulate container
+ dc, err := dockerClient.NewClientWithOpts(
+ dockerClient.FromEnv,
+ dockerClient.WithAPIVersionNegotiation(),
+ )
+ must.NotError(t, "failed creating docker client", err)
+
+ // Configure the OIDC Provider by writing a config fragment
+ err = dockerutil.WriteFileIntoContainer(
+ t,
+ dc,
+ deployment.ContainerID(t, "hs1"),
+ "/conf/homeserver.d/oidc_provider.yaml",
+ []byte(OIDC_HOMESERVER_CONFIG),
+ )
+ if err != nil {
+ t.Fatalf("Failed to write updated config to container: %v", err)
+ }
+
+ // Restart the homeserver to apply the new config
+ deployment.StopServer(t, "hs1")
+ // Careful: port number changes here
+ deployment.StartServer(t, "hs1")
+ // Must get after the restart so the port number is correct
+ unauthedClient := deployment.UnauthenticatedClient(t, "hs1")
+
+ // Test that trying to log in with an OIDC provider that is down
+ // causes an HTML error page to be shown to the user.
+ // (This replaces the redirect that would happen if the provider was
+ // up.)
+ //
+ // More importantly, implicitly tests that Synapse can start up
+ // and answer requests even though an OIDC provider is down.
+ t.Run("/login/sso/redirect shows HTML error", func(t *testing.T) {
+ // Build a request to the /redirect/ endpoint, that would normally be navigated to
+ // by the user's browser in order to start the login flow.
+ queryParams := url.Values{}
+ queryParams.Add("redirectUrl", "http://redirect.invalid/redirect")
+ res := unauthedClient.Do(t, "GET", []string{"_matrix", "client", "v3", "login", "sso", "redirect", "oidc-test_provider"},
+ client.WithQueries(queryParams),
+ )
+
+ body := must.MatchResponse(t, res, match.HTTPResponse{
+ // Should get a 503
+ StatusCode: http.StatusServiceUnavailable,
+
+ Headers: map[string]string{
+ // Should get an HTML page explaining the problem to the user
+ "Content-Type": "text/html; charset=utf-8",
+ },
+ })
+
+ bodyText := string(body)
+
+ // The HTML page contains phrases from the template we expect
+ if !strings.Contains(bodyText, "login provider is unavailable right now") {
+ t.Fatalf("Keyword not found in HTML error page, got %s", bodyText)
+ }
+ })
+}
diff --git a/docker/complement/conf/start_for_complement.sh b/docker/complement/conf/start_for_complement.sh
index da1b26a2836..e0d30abed36 100755
--- a/docker/complement/conf/start_for_complement.sh
+++ b/docker/complement/conf/start_for_complement.sh
@@ -128,6 +128,10 @@ openssl x509 -req -in /conf/server.tls.csr \
export SYNAPSE_TLS_CERT=/conf/server.tls.crt
export SYNAPSE_TLS_KEY=/conf/server.tls.key
+# Add a directory for tests to add config overrides if they want
+mkdir --parents /conf/homeserver.d
+export _SYNAPSE_COMPLEMENT_EXTRA_CONFIG_DIR=/conf/homeserver.d
+
# Run the script that writes the necessary config files and starts supervisord, which in turn
# starts everything else
exec /configure_workers_and_start.py "$@"
diff --git a/docker/conf-workers/synapse.supervisord.conf.j2 b/docker/conf-workers/synapse.supervisord.conf.j2
index 4fb11b259e5..9388b1ff0ef 100644
--- a/docker/conf-workers/synapse.supervisord.conf.j2
+++ b/docker/conf-workers/synapse.supervisord.conf.j2
@@ -5,10 +5,16 @@ command=/usr/local/bin/python -m synapse.app.complement_fork_starter
{{ main_config_path }}
synapse.app.homeserver
--config-path="{{ main_config_path }}"
+ {%- if extra_config_dir %}
+ --config-path="{{ extra_config_dir }}"
+ {%- endif %}
--config-path=/conf/workers/shared.yaml
{%- for worker in workers %}
-- {{ worker.app }}
--config-path="{{ main_config_path }}"
+ {%- if extra_config_dir %}
+ --config-path="{{ extra_config_dir }}"
+ {%- endif %}
--config-path=/conf/workers/shared.yaml
--config-path=/conf/workers/{{ worker.name }}.yaml
{%- endfor %}
@@ -24,6 +30,9 @@ exitcodes=0
environment=http_proxy="%(ENV_SYNAPSE_HTTP_PROXY)s",https_proxy="%(ENV_SYNAPSE_HTTPS_PROXY)s",no_proxy="%(ENV_SYNAPSE_NO_PROXY)s"
command=/usr/local/bin/prefix-log /usr/local/bin/python -m synapse.app.homeserver
--config-path="{{ main_config_path }}"
+ {%- if extra_config_dir %}
+ --config-path="{{ extra_config_dir }}"
+ {%- endif %}
--config-path=/conf/workers/shared.yaml
priority=10
# Log startup failures to supervisord's stdout/err
diff --git a/docker/configure_workers_and_start.py b/docker/configure_workers_and_start.py
index ed15e8efef5..1b8d4f9989e 100755
--- a/docker/configure_workers_and_start.py
+++ b/docker/configure_workers_and_start.py
@@ -856,6 +856,7 @@ def parse_worker_types(
def generate_worker_files(
environ: Mapping[str, str],
config_path: str,
+ extra_config_dir: str | None,
data_dir: str,
requested_workers: list[Worker],
) -> None:
@@ -865,6 +866,7 @@ def generate_worker_files(
Args:
environ: os.environ instance.
config_path: The location of the generated Synapse main worker config file.
+ extra_config_dir: A directory in which extra Synapse configuration files will be loaded.
data_dir: The location of the synapse data directory. Where log and
user-facing config files live.
requested_workers: A list of requested workers
@@ -1258,6 +1260,7 @@ def generate_worker_files(
"/etc/supervisor/conf.d/synapse.conf",
workers=worker_descriptors,
main_config_path=config_path,
+ extra_config_dir=extra_config_dir,
use_forking_launcher=environ.get("SYNAPSE_USE_EXPERIMENTAL_FORKING_LAUNCHER"),
)
@@ -1318,6 +1321,9 @@ def main(args: list[str], environ: MutableMapping[str, str]) -> None:
config_dir = environ.get("SYNAPSE_CONFIG_DIR", "/data")
config_path = environ.get("SYNAPSE_CONFIG_PATH", config_dir + "/homeserver.yaml")
+ # All files in this directory will be loaded as `homeserver.yaml` snippets
+ # Currently only intended for use by Complement
+ extra_config_dir = environ.get("_SYNAPSE_COMPLEMENT_EXTRA_CONFIG_DIR", None)
data_dir = environ.get("SYNAPSE_DATA_DIR", "/data")
# override SYNAPSE_NO_TLS, we don't support TLS in worker mode,
@@ -1352,6 +1358,7 @@ def main(args: list[str], environ: MutableMapping[str, str]) -> None:
generate_worker_files(
environ=environ,
config_path=config_path,
+ extra_config_dir=extra_config_dir,
data_dir=data_dir,
requested_workers=requested_workers,
)
diff --git a/synapse/app/_base.py b/synapse/app/_base.py
index 7f4855f36bd..55df4c382e6 100644
--- a/synapse/app/_base.py
+++ b/synapse/app/_base.py
@@ -700,12 +700,10 @@ async def start(hs: "HomeServer", *, freeze: bool = True) -> None:
# Load the OIDC provider metadatas, if OIDC is enabled.
if hs.config.oidc.oidc_enabled:
oidc = hs.get_oidc_handler()
+ # Preload the provider metadata.
+ # This will spawn fire-and-forget background processes.
# Loading the provider metadata also ensures the provider config is valid.
- #
- # FIXME: It feels a bit strange to validate and block on startup as one of these
- # OIDC providers could be temporarily unavailable and cause Synapse to be unable
- # to start.
- await oidc.load_metadata()
+ oidc.preload_metadata()
# Load the certificate from disk.
refresh_certificate(hs)
diff --git a/synapse/handlers/oidc.py b/synapse/handlers/oidc.py
index 429a7393800..09b19056a72 100644
--- a/synapse/handlers/oidc.py
+++ b/synapse/handlers/oidc.py
@@ -49,18 +49,20 @@
MacaroonInvalidSignatureException,
)
+from twisted.internet import defer
+from twisted.internet.defer import Deferred
from twisted.web.client import readBody
from twisted.web.http_headers import Headers
from synapse.api.errors import SynapseError
from synapse.config import ConfigError
from synapse.config.oidc import OidcProviderClientSecretJwtKey, OidcProviderConfig
-from synapse.handlers.sso import MappingException, UserAttributes
+from synapse.handlers.sso import MappingException, SsoSetupError, UserAttributes
from synapse.http.server import finish_request
from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable
-from synapse.module_api import ModuleApi
+from synapse.metrics.background_process_metrics import wrap_as_background_process
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util.caches.cached_call import RetryOnExceptionCachedCall
from synapse.util.clock import Clock
@@ -69,6 +71,7 @@
from synapse.util.templates import _localpart_from_email_filter
if TYPE_CHECKING:
+ from synapse.module_api import ModuleApi
from synapse.server import HomeServer
logger = logging.getLogger(__name__)
@@ -123,6 +126,8 @@ class OidcHandler:
def __init__(self, hs: "HomeServer"):
self._sso_handler = hs.get_sso_handler()
+ # Needed for wrap_as_background_process
+ self.hs = hs
provider_confs = hs.config.oidc.oidc_providers
# we should not have been instantiated if there is no configured provider.
@@ -134,20 +139,47 @@ def __init__(self, hs: "HomeServer"):
for p in provider_confs
}
- async def load_metadata(self) -> None:
- """Validate the config and load the metadata from the remote endpoint.
+ @wrap_as_background_process("preload_oidc_metadata")
+ async def _preload_metadata_one_provider(
+ self, idp_id: str, p: "OidcProvider"
+ ) -> None:
+ """Attempt to preload the metadata from a single OIDC provider's remote endpoint
+ in the background.
- Called at startup to ensure we have everything we need.
+ Will not raise exceptions, but will log at CRITICAL if an OIDC provider is broken.
"""
+
+ logger.info("Preloading OIDC provider %r", idp_id)
+ try:
+ await p.load_metadata()
+ if not p._uses_userinfo:
+ await p.load_jwks()
+ except Exception:
+ logger.critical(
+ # Include 'login' keyword for searchability.
+ "Error while preloading OIDC provider %r. Login may be broken!",
+ idp_id,
+ exc_info=True,
+ )
+
+ def preload_metadata(self) -> "Deferred[list[tuple[bool, None]]]":
+ """Attempt to preload the metadata from all the OIDC providers' remote endpoints
+ in the background.
+
+ Will not raise exceptions, but will log at CRITICAL if an OIDC provider is broken.
+
+ Can be **optionally** awaited in which case it will resolve when all
+ preloads are finished.
+ """
+
+ to_wait = []
for idp_id, p in self._providers.items():
- try:
- await p.load_metadata()
- if not p._uses_userinfo:
- await p.load_jwks()
- except Exception as e:
- raise Exception(
- "Error while initialising OIDC provider %r" % (idp_id,)
- ) from e
+ to_wait.append(self._preload_metadata_one_provider(idp_id, p))
+
+ return defer.DeferredList(
+ to_wait,
+ consumeErrors=True,
+ )
async def handle_oidc_callback(self, request: SynapseRequest) -> None:
"""Handle an incoming request to /_synapse/client/oidc/callback
@@ -359,6 +391,18 @@ def __str__(self) -> str:
return self.error
+class OidcDiscoveryError(SsoSetupError):
+ """
+ Used to catch and mark errors when performing OIDC discovery.
+ """
+
+
+class OidcMetadataError(SsoSetupError):
+ """
+ Used to catch and mark errors in the OIDC metadata configuration.
+ """
+
+
class OidcProvider:
"""Wraps the config for a single OIDC IdentityProvider
@@ -372,6 +416,9 @@ def __init__(
macaroon_generator: MacaroonGenerator,
provider: OidcProviderConfig,
):
+ # Needed here to break import loops
+ from synapse.module_api import ModuleApi
+
self._store = hs.get_datastores().main
self._clock = hs.get_clock()
@@ -644,9 +691,15 @@ async def _load_metadata(self) -> OpenIDProviderMetadata:
# load any data from the discovery endpoint, if enabled
if self._config.discover:
- url = get_well_known_url(self._config.issuer, external=True)
- metadata_response = await self._http_client.get_json(url)
- metadata.update(metadata_response)
+ try:
+ url = get_well_known_url(self._config.issuer, external=True)
+ metadata_response = await self._http_client.get_json(url)
+ metadata.update(metadata_response)
+ except Exception as e:
+ # This `Exception` bound is a bit broad, but at least expecting
+ # `twisted.internet.error.ConnectionRefusedError`
+ # and likely many other network or JSON errors.
+ raise OidcDiscoveryError() from e
# override any discovered data with any settings in our config
if self._config.authorization_endpoint:
@@ -671,7 +724,12 @@ async def _load_metadata(self) -> OpenIDProviderMetadata:
self._config.id_token_signing_alg_values_supported
)
- self._validate_metadata(metadata)
+ try:
+ self._validate_metadata(metadata)
+ except ValueError as e:
+ # Wrap this error so that we can special-case it higher up
+ # Pass through `str(e)` as the message so tests can match it.
+ raise OidcMetadataError(str(e)) from e
return metadata
@@ -1685,7 +1743,7 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
This is the default mapping provider.
"""
- def __init__(self, config: JinjaOidcMappingConfig, module_api: ModuleApi):
+ def __init__(self, config: JinjaOidcMappingConfig, module_api: "ModuleApi"):
self._config = config
@staticmethod
diff --git a/synapse/handlers/sso.py b/synapse/handlers/sso.py
index 0b2587f94fc..bb5ca329e00 100644
--- a/synapse/handlers/sso.py
+++ b/synapse/handlers/sso.py
@@ -70,6 +70,15 @@ class MappingException(Exception):
"""
+class SsoSetupError(Exception):
+ """
+ Used to catch and tag errors relating to an SSO setup's.
+
+ Used as the superclass of specific error classes,
+ as note we can't e.g. import the OIDC module unless OIDC dependencies are installed.
+ """
+
+
class SsoIdentityProvider(Protocol):
"""Abstract base class to be implemented by SSO Identity Providers
diff --git a/synapse/res/templates/sso_error.html b/synapse/res/templates/sso_error.html
index 6fa36c11c9d..e7e34c7293d 100644
--- a/synapse/res/templates/sso_error.html
+++ b/synapse/res/templates/sso_error.html
@@ -20,6 +20,27 @@
You are not allowed to log in here.
+{% elif error == "provider_unavailable" %}
+
+
This login provider is unavailable right now.
+
+ There has been a problem which means that it's not currently possible
+ to log in with that provider.
+
+
+ If your account has a password, or is connected to other login providers,
+ try those methods to log in.
+
+
+ This issue could be temporary, so please try again later.
+ If the problem persists, please contact the server's administrator.
+
+
+ If you are the administrator of this server, please check the server logs for more information.
+ This could be caused by a server misconfiguration or an issue with the provider.
+
+
+ {% include "sso_footer.html" without context %}
{% else %}
There was an error
diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index fe3cb9aa3df..a49b27d009f 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -18,7 +18,6 @@
# [This file includes modifications made by New Vector Limited]
#
#
-
import logging
import re
from typing import (
@@ -42,7 +41,7 @@
from synapse.api.ratelimiting import Ratelimiter
from synapse.api.urls import CLIENT_API_PREFIX
from synapse.appservice import ApplicationService
-from synapse.handlers.sso import SsoIdentityProvider
+from synapse.handlers.sso import SsoIdentityProvider, SsoSetupError
from synapse.http import get_request_uri
from synapse.http.server import HttpServer, finish_request
from synapse.http.servlet import (
@@ -679,11 +678,26 @@ async def on_GET(self, request: SynapseRequest, idp_id: str | None = None) -> No
args: dict[bytes, list[bytes]] = request.args # type: ignore
client_redirect_url = parse_bytes_from_args(args, "redirectUrl", required=True)
- sso_url = await self._sso_handler.handle_redirect_request(
- request,
- client_redirect_url,
- idp_id,
- )
+ try:
+ sso_url = await self._sso_handler.handle_redirect_request(
+ request,
+ client_redirect_url,
+ idp_id,
+ )
+ except SsoSetupError:
+ logger.exception(
+ "Login redirect failed because SSO/identity provider %r unavailable",
+ idp_id,
+ )
+ # Show an error page that is slightly more friendly than JSON
+ self._sso_handler.render_error(
+ request,
+ "provider_unavailable",
+ "This login provider is currently unavailable on this homeserver.",
+ code=503,
+ )
+ return
+
logger.info("Redirecting to %s", sso_url)
request.redirect(sso_url)
finish_request(request)
diff --git a/tests/handlers/test_oidc.py b/tests/handlers/test_oidc.py
index 4583afb625f..62b84c77a4d 100644
--- a/tests/handlers/test_oidc.py
+++ b/tests/handlers/test_oidc.py
@@ -19,7 +19,7 @@
#
#
import os
-from typing import Any, Awaitable, ContextManager
+from typing import Any, ContextManager
from unittest.mock import ANY, AsyncMock, Mock, patch
from urllib.parse import parse_qs, urlparse
@@ -29,6 +29,7 @@
from synapse.handlers.sso import MappingException
from synapse.http.site import SynapseRequest
+from synapse.logging.context import make_deferred_yieldable
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
from synapse.util.clock import Clock
@@ -44,7 +45,7 @@
from authlib.oidc.core import UserInfo
from authlib.oidc.discovery import OpenIDProviderMetadata
- from synapse.handlers.oidc import Token, UserAttributeDict
+ from synapse.handlers.oidc import OidcMetadataError, Token, UserAttributeDict
HAS_OIDC = True
except ImportError:
@@ -264,6 +265,29 @@ def test_discovery(self) -> None:
self.get_success(self.provider.load_metadata())
self.fake_server.get_metadata_handler.assert_not_called()
+ @override_config({"oidc_config": {**DEFAULT_CONFIG, "discover": True}})
+ def test_startup_metadata_preload_failure_is_logged(self) -> None:
+ """
+ The metadata preload on startup causes logs to be emitted, but no
+ exceptions.
+ """
+ with self.fake_server.buggy_endpoint(metadata=True):
+ with self.assertLogs("synapse.handlers.oidc", level="CRITICAL") as logs:
+ self.get_success(
+ make_deferred_yieldable(self.handler.preload_metadata())
+ )
+
+ self.assertIn(
+ "CRITICAL:synapse.handlers.oidc:Error while preloading OIDC provider 'oidc'. Login may be broken!",
+ [line for record in logs.output for line in record.split("\n")],
+ )
+
+ # Even though the preload failed, if we try to load the metadata later,
+ # the attempt still goes through.
+ self.fake_server.get_metadata_handler.assert_not_called()
+ self.get_success(self.provider.load_metadata())
+ self.fake_server.get_metadata_handler.assert_called_once()
+
@override_config({"oidc_config": {**EXPLICIT_ENDPOINT_CONFIG, "discover": True}})
def test_discovery_with_explicit_config(self) -> None:
"""
@@ -351,49 +375,53 @@ def test_validate_config(self) -> None:
"""Provider metadatas are extensively validated."""
h = self.provider
- def force_load_metadata() -> Awaitable[None]:
+ def force_load_metadata() -> None:
async def force_load() -> "OpenIDProviderMetadata":
return await h.load_metadata(force=True)
- return get_awaitable_result(force_load())
+ get_awaitable_result(force_load())
# Default test config does not throw
force_load_metadata()
with self.metadata_edit({"issuer": None}):
- self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
+ self.assertRaisesRegex(OidcMetadataError, "issuer", force_load_metadata)
with self.metadata_edit({"issuer": "http://insecure/"}):
- self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
+ self.assertRaisesRegex(OidcMetadataError, "issuer", force_load_metadata)
with self.metadata_edit({"issuer": "https://invalid/?because=query"}):
- self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
+ self.assertRaisesRegex(OidcMetadataError, "issuer", force_load_metadata)
with self.metadata_edit({"authorization_endpoint": None}):
self.assertRaisesRegex(
- ValueError, "authorization_endpoint", force_load_metadata
+ OidcMetadataError, "authorization_endpoint", force_load_metadata
)
with self.metadata_edit({"authorization_endpoint": "http://insecure/auth"}):
self.assertRaisesRegex(
- ValueError, "authorization_endpoint", force_load_metadata
+ OidcMetadataError, "authorization_endpoint", force_load_metadata
)
with self.metadata_edit({"token_endpoint": None}):
- self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata)
+ self.assertRaisesRegex(
+ OidcMetadataError, "token_endpoint", force_load_metadata
+ )
with self.metadata_edit({"token_endpoint": "http://insecure/token"}):
- self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata)
+ self.assertRaisesRegex(
+ OidcMetadataError, "token_endpoint", force_load_metadata
+ )
with self.metadata_edit({"jwks_uri": None}):
- self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata)
+ self.assertRaisesRegex(OidcMetadataError, "jwks_uri", force_load_metadata)
with self.metadata_edit({"jwks_uri": "http://insecure/jwks.json"}):
- self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata)
+ self.assertRaisesRegex(OidcMetadataError, "jwks_uri", force_load_metadata)
with self.metadata_edit({"response_types_supported": ["id_token"]}):
self.assertRaisesRegex(
- ValueError, "response_types_supported", force_load_metadata
+ OidcMetadataError, "response_types_supported", force_load_metadata
)
with self.metadata_edit(
@@ -406,7 +434,7 @@ async def force_load() -> "OpenIDProviderMetadata":
{"token_endpoint_auth_methods_supported": ["client_secret_post"]}
):
self.assertRaisesRegex(
- ValueError,
+ OidcMetadataError,
"token_endpoint_auth_methods_supported",
force_load_metadata,
)
@@ -423,7 +451,9 @@ async def force_load() -> "OpenIDProviderMetadata":
h._scopes = []
self.assertTrue(h._uses_userinfo)
with self.metadata_edit({"userinfo_endpoint": None}):
- self.assertRaisesRegex(ValueError, "userinfo_endpoint", force_load_metadata)
+ self.assertRaisesRegex(
+ OidcMetadataError, "userinfo_endpoint", force_load_metadata
+ )
with self.metadata_edit({"jwks_uri": None}):
# Shouldn't raise with a valid userinfo, even without jwks
diff --git a/tests/server.py b/tests/server.py
index d17b2478e3b..55860701dae 100644
--- a/tests/server.py
+++ b/tests/server.py
@@ -1358,6 +1358,16 @@ def thread_pool() -> threadpool.ThreadPool:
hs.get_media_sender_thread_pool = thread_pool # type: ignore[method-assign]
+ # Load the OIDC provider metadatas, if OIDC is enabled.
+ # This matches `start` in synapse/app/_base.py
+ #
+ # TODO: Extract common startup logic somewhere cleaner
+ if hs.config.oidc.oidc_enabled:
+ oidc = hs.get_oidc_handler()
+ # Preload the provider metadata.
+ # This will spawn fire-and-forget background processes.
+ oidc.preload_metadata()
+
# Load any configured modules into the homeserver
module_api = hs.get_module_api()
for module, module_config in hs.config.modules.loaded_modules:
From 40d35a95e2ce56982f839f2d5f01bdad34e65453 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 25 Mar 2026 13:21:38 +0000
Subject: [PATCH 099/121] Bump tokio from 1.49.0 to 1.50.0 (#19596)
---
Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index c15688814ca..a004747af5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1410,9 +1410,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
From f2b325f86c7f45159b462aa295e6f69984d24748 Mon Sep 17 00:00:00 2001
From: Eric Eastwood
Date: Wed, 25 Mar 2026 15:33:17 -0500
Subject: [PATCH 100/121] Demystify and deprecate `HomeserverTestCase.pump()`
(Twisted reactor/clock) (#19602)
Spawning from
https://github.com/element-hq/synapse/pull/18416#discussion_r2967619735
---
changelog.d/19602.misc.1 | 1 +
changelog.d/19602.misc.2 | 1 +
tests/unittest.py | 37 ++++++++++++++++++++++++++++++++++++-
3 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/19602.misc.1
create mode 100644 changelog.d/19602.misc.2
diff --git a/changelog.d/19602.misc.1 b/changelog.d/19602.misc.1
new file mode 100644
index 00000000000..222f26980db
--- /dev/null
+++ b/changelog.d/19602.misc.1
@@ -0,0 +1 @@
+Update `HomeserverTestCase.pump()` docstring to demystify behavior (Twisted reactor/clock).
diff --git a/changelog.d/19602.misc.2 b/changelog.d/19602.misc.2
new file mode 100644
index 00000000000..4cadad653cf
--- /dev/null
+++ b/changelog.d/19602.misc.2
@@ -0,0 +1 @@
+Deprecate `HomeserverTestCase.pump()` in favor of more direct `HomeserverTestCase.reactor.advance(...)` usage.
diff --git a/tests/unittest.py b/tests/unittest.py
index 6022c750d03..03db9a42827 100644
--- a/tests/unittest.py
+++ b/tests/unittest.py
@@ -697,8 +697,43 @@ async def run_bg_updates() -> None:
def pump(self, by: float = 0.0) -> None:
"""
- Pump the reactor enough that Deferreds will fire.
+ XXX: Deprecated: This method is deprecated. Use `self.reactor.advance(...)`
+ directly instead.
+
+ Pump the reactor enough that `clock.call_later` scheduled callbacks will fire.
+
+ To demystify this function, it simply advances time by the number of seconds
+ specified (defaults to `0`, we also multiply by 100, so `pump(1)` is 100 seconds
+ in 1 second steps/increments) whilst calling any pending callbacks, allowing any
+ queued/pending tasks to run because enough time has passed.
+
+ So for example, if you have some Synapse code that does
+ `clock.call_later(Duration(seconds=2), callback)`, then calling
+ `self.pump(by=0.02)` will advance time by 2 seconds, which is enough for that
+ callback to be ready to run now. Same for `clock.sleep(...)` ,
+ `clock.looping_call(...)`, and whatever other clock utilities that use
+ `clock.call_later` under the hood for scheduling tasks. Trying to use
+ `pump(by=...)` with exact math to meet a specific deadline feels pretty dirty
+ though which is why we recommend using `self.reactor.advance(...)` directly
+ nowadays.
+
+ We don't have any exact historical context for why `pump()` was introduced into
+ the codebase beyond the code itself. We assume that we multiply by 100 so that
+ when you use the clock to schedule something that schedules more things, it
+ tries to run the whole chain to completion.
+
+ XXX: If you're having to call this function, please call out in comments, which
+ scheduled thing you're aiming to trigger. Please also check whether the
+ `pump(...)` is even necessary as it was often misused.
+
+ Args:
+ by: The time increment in seconds to advance time by. We will advance time
+ in 100 steps, each step by this value.
"""
+ # We multiply by 100, so `pump(1)` actually advances time by 100 seconds in 1
+ # second steps/increments. We assume this was done so that when you use the
+ # clock to schedule something that schedules more things, it tries to run the
+ # whole chain to completion.
self.reactor.pump([by] * 100)
def get_success(self, d: Awaitable[TV], by: float = 0.0) -> TV:
From f545aa4f33377f2c68aad7032afbe5a395c35cbc Mon Sep 17 00:00:00 2001
From: Erik Johnston
Date: Thu, 26 Mar 2026 09:17:31 +0000
Subject: [PATCH 101/121] Port `RoomVersion` to Rust (#19589)
Principally so that we can share the same room version configuration
between Python and Rust.
For the most part, this is a direct port. Some special handling has had
to go into `KNOWN_ROOM_VERSIONS` so that it can be sensibly shared
between Python and Rust, since we do update it during config parsing.
---------
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
changelog.d/19589.misc | 1 +
rust/src/lib.rs | 2 +
rust/src/room_versions.rs | 825 +++++++++++++++++++++++++
synapse/api/room_versions.py | 496 +--------------
synapse/config/experimental.py | 3 +-
synapse/event_auth.py | 24 +-
synapse/synapse_rust/push.pyi | 2 +-
synapse/synapse_rust/room_versions.pyi | 142 +++++
tests/events/test_utils.py | 12 +-
9 files changed, 995 insertions(+), 512 deletions(-)
create mode 100644 changelog.d/19589.misc
create mode 100644 rust/src/room_versions.rs
create mode 100644 synapse/synapse_rust/room_versions.pyi
diff --git a/changelog.d/19589.misc b/changelog.d/19589.misc
new file mode 100644
index 00000000000..f3c15326c67
--- /dev/null
+++ b/changelog.d/19589.misc
@@ -0,0 +1 @@
+Port `RoomVersion` to Rust.
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 83b8de7b64a..a6e01fce641 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -15,6 +15,7 @@ pub mod matrix_const;
pub mod msc4388_rendezvous;
pub mod push;
pub mod rendezvous;
+pub mod room_versions;
pub mod segmenter;
lazy_static! {
@@ -58,6 +59,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
rendezvous::register_module(py, m)?;
msc4388_rendezvous::register_module(py, m)?;
segmenter::register_module(py, m)?;
+ room_versions::register_module(py, m)?;
Ok(())
}
diff --git a/rust/src/room_versions.rs b/rust/src/room_versions.rs
new file mode 100644
index 00000000000..c0b304e18c9
--- /dev/null
+++ b/rust/src/room_versions.rs
@@ -0,0 +1,825 @@
+/*
+ * This file is licensed under the Affero General Public License (AGPL) version 3.
+ *
+ * Copyright (C) 2026 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:
+ * .
+ */
+
+//! Rust implementation of room version definitions.
+
+use std::sync::{Arc, LazyLock, RwLock};
+
+use pyo3::{
+ exceptions::{PyKeyError, PyRuntimeError},
+ prelude::*,
+ types::{PyFrozenSet, PyIterator, PyModule, PyModuleMethods},
+ Bound, IntoPyObjectExt, PyResult, Python,
+};
+
+/// Internal enum for tracking the version of the event format,
+/// independently of the room version.
+///
+/// To reduce confusion, the event format versions are named after the room
+/// versions that they were used or introduced in.
+/// The concept of an 'event format version' is specific to Synapse (the
+/// specification does not mention this term.)
+#[pyclass(frozen)]
+pub struct EventFormatVersions {}
+
+#[pymethods]
+impl EventFormatVersions {
+ /// $id:server event id format: used for room v1 and v2
+ #[classattr]
+ const ROOM_V1_V2: i32 = 1;
+ /// MSC1659-style $hash event id format: used for room v3
+ #[classattr]
+ const ROOM_V3: i32 = 2;
+ /// MSC1884-style $hash format: introduced for room v4
+ #[classattr]
+ const ROOM_V4_PLUS: i32 = 3;
+ /// MSC4291 room IDs as hashes: introduced for room HydraV11
+ #[classattr]
+ const ROOM_V11_HYDRA_PLUS: i32 = 4;
+}
+
+/// Enum to identify the state resolution algorithms.
+#[pyclass(frozen)]
+pub struct StateResolutionVersions {}
+
+#[pymethods]
+impl StateResolutionVersions {
+ /// Room v1 state res
+ #[classattr]
+ const V1: i32 = 1;
+ /// MSC1442 state res: room v2 and later
+ #[classattr]
+ const V2: i32 = 2;
+ /// MSC4297 state res
+ #[classattr]
+ const V2_1: i32 = 3;
+}
+
+/// Room disposition constants.
+#[pyclass(frozen)]
+pub struct RoomDisposition {}
+
+#[pymethods]
+impl RoomDisposition {
+ #[classattr]
+ const STABLE: &'static str = "stable";
+ #[classattr]
+ const UNSTABLE: &'static str = "unstable";
+}
+
+/// Enum for listing possible MSC3931 room version feature flags, for push rules.
+#[pyclass(frozen)]
+pub struct PushRuleRoomFlag {}
+
+#[pymethods]
+impl PushRuleRoomFlag {
+ /// MSC3932: Room version supports MSC1767 Extensible Events.
+ #[classattr]
+ const EXTENSIBLE_EVENTS: &'static str = "org.matrix.msc3932.extensible_events";
+}
+
+/// An object which describes the unique attributes of a room version.
+#[pyclass(frozen, eq, hash, get_all)]
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct RoomVersion {
+ /// The identifier for this version.
+ pub identifier: &'static str,
+ /// One of the RoomDisposition constants.
+ pub disposition: &'static str,
+ /// One of the EventFormatVersions constants.
+ pub event_format: i32,
+ /// One of the StateResolutionVersions constants.
+ pub state_res: i32,
+ pub enforce_key_validity: bool,
+ /// Before MSC2432, m.room.aliases had special auth rules and redaction rules.
+ pub special_case_aliases_auth: bool,
+ /// Strictly enforce canonicaljson, do not allow:
+ /// * Integers outside the range of [-2^53 + 1, 2^53 - 1]
+ /// * Floats
+ /// * NaN, Infinity, -Infinity
+ pub strict_canonicaljson: bool,
+ /// MSC2209: Check 'notifications' key while verifying
+ /// m.room.power_levels auth rules.
+ pub limit_notifications_power_levels: bool,
+ /// MSC3820: No longer include the creator in m.room.create events (room version 11).
+ pub implicit_room_creator: bool,
+ /// MSC3820: Apply updated redaction rules algorithm from room version 11.
+ pub updated_redaction_rules: bool,
+ /// Support the 'restricted' join rule.
+ pub restricted_join_rule: bool,
+ /// Support for the proper redaction rules for the restricted join rule.
+ /// This requires restricted_join_rule to be enabled.
+ pub restricted_join_rule_fix: bool,
+ /// Support the 'knock' join rule.
+ pub knock_join_rule: bool,
+ /// MSC3389: Protect relation information from redaction.
+ pub msc3389_relation_redactions: bool,
+ /// Support the 'knock_restricted' join rule.
+ pub knock_restricted_join_rule: bool,
+ /// Enforce integer power levels.
+ pub enforce_int_power_levels: bool,
+ /// MSC3931: Adds a push rule condition for "room version feature flags", making
+ /// some push rules room version dependent. Note that adding a flag to this list
+ /// is not enough to mark it "supported": the push rule evaluator also needs to
+ /// support the flag. Unknown flags are ignored by the evaluator, making conditions
+ /// fail if used. Values from PushRuleRoomFlag.
+ pub msc3931_push_features: &'static [&'static str],
+ /// MSC3757: Restricting who can overwrite a state event.
+ pub msc3757_enabled: bool,
+ /// MSC4289: Creator power enabled.
+ pub msc4289_creator_power_enabled: bool,
+ /// MSC4291: Room IDs as hashes of the create event.
+ pub msc4291_room_ids_as_hashes: bool,
+ /// Set of room versions where Synapse strictly enforces event key size limits
+ /// in bytes, rather than in codepoints.
+ ///
+ /// In these room versions, we are stricter with event size validation.
+ pub strict_event_byte_limits_room_versions: bool,
+}
+
+const ROOM_VERSION_V1: RoomVersion = RoomVersion {
+ identifier: "1",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V1_V2,
+ state_res: StateResolutionVersions::V1,
+ enforce_key_validity: false,
+ special_case_aliases_auth: true,
+ strict_canonicaljson: false,
+ limit_notifications_power_levels: false,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V2: RoomVersion = RoomVersion {
+ identifier: "2",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V1_V2,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: false,
+ special_case_aliases_auth: true,
+ strict_canonicaljson: false,
+ limit_notifications_power_levels: false,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V3: RoomVersion = RoomVersion {
+ identifier: "3",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V3,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: false,
+ special_case_aliases_auth: true,
+ strict_canonicaljson: false,
+ limit_notifications_power_levels: false,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V4: RoomVersion = RoomVersion {
+ identifier: "4",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: false,
+ special_case_aliases_auth: true,
+ strict_canonicaljson: false,
+ limit_notifications_power_levels: false,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V5: RoomVersion = RoomVersion {
+ identifier: "5",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: true,
+ strict_canonicaljson: false,
+ limit_notifications_power_levels: false,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V6: RoomVersion = RoomVersion {
+ identifier: "6",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: false,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V7: RoomVersion = RoomVersion {
+ identifier: "7",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: false,
+ restricted_join_rule_fix: false,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V8: RoomVersion = RoomVersion {
+ identifier: "8",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: false,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V9: RoomVersion = RoomVersion {
+ identifier: "9",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: false,
+ enforce_int_power_levels: false,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V10: RoomVersion = RoomVersion {
+ identifier: "10",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+/// MSC3389 (Redaction changes for events with a relation) based on room version "10".
+const ROOM_VERSION_MSC3389V10: RoomVersion = RoomVersion {
+ identifier: "org.matrix.msc3389.10",
+ disposition: RoomDisposition::UNSTABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: true, // Changed from v10
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: true,
+};
+
+/// MSC1767 (Extensible Events) based on room version "10".
+const ROOM_VERSION_MSC1767V10: RoomVersion = RoomVersion {
+ identifier: "org.matrix.msc1767.10",
+ disposition: RoomDisposition::UNSTABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[PushRuleRoomFlag::EXTENSIBLE_EVENTS],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+/// MSC3757 (Restricting who can overwrite a state event) based on room version "10".
+const ROOM_VERSION_MSC3757V10: RoomVersion = RoomVersion {
+ identifier: "org.matrix.msc3757.10",
+ disposition: RoomDisposition::UNSTABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: false,
+ updated_redaction_rules: false,
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: true,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: false,
+};
+
+const ROOM_VERSION_V11: RoomVersion = RoomVersion {
+ identifier: "11",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: true, // Used by MSC3820
+ updated_redaction_rules: true, // Used by MSC3820
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: true, // Changed from v10
+};
+
+/// MSC3757 (Restricting who can overwrite a state event) based on room version "11".
+const ROOM_VERSION_MSC3757V11: RoomVersion = RoomVersion {
+ identifier: "org.matrix.msc3757.11",
+ disposition: RoomDisposition::UNSTABLE,
+ event_format: EventFormatVersions::ROOM_V4_PLUS,
+ state_res: StateResolutionVersions::V2,
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: true, // Used by MSC3820
+ updated_redaction_rules: true, // Used by MSC3820
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: true,
+ msc4289_creator_power_enabled: false,
+ msc4291_room_ids_as_hashes: false,
+ strict_event_byte_limits_room_versions: true,
+};
+
+const ROOM_VERSION_HYDRA_V11: RoomVersion = RoomVersion {
+ identifier: "org.matrix.hydra.11",
+ disposition: RoomDisposition::UNSTABLE,
+ event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
+ state_res: StateResolutionVersions::V2_1, // Changed from v11
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: true, // Used by MSC3820
+ updated_redaction_rules: true, // Used by MSC3820
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: true, // Changed from v11
+ msc4291_room_ids_as_hashes: true, // Changed from v11
+ strict_event_byte_limits_room_versions: true,
+};
+
+const ROOM_VERSION_V12: RoomVersion = RoomVersion {
+ identifier: "12",
+ disposition: RoomDisposition::STABLE,
+ event_format: EventFormatVersions::ROOM_V11_HYDRA_PLUS,
+ state_res: StateResolutionVersions::V2_1, // Changed from v11
+ enforce_key_validity: true,
+ special_case_aliases_auth: false,
+ strict_canonicaljson: true,
+ limit_notifications_power_levels: true,
+ implicit_room_creator: true, // Used by MSC3820
+ updated_redaction_rules: true, // Used by MSC3820
+ restricted_join_rule: true,
+ restricted_join_rule_fix: true,
+ knock_join_rule: true,
+ msc3389_relation_redactions: false,
+ knock_restricted_join_rule: true,
+ enforce_int_power_levels: true,
+ msc3931_push_features: &[],
+ msc3757_enabled: false,
+ msc4289_creator_power_enabled: true, // Changed from v11
+ msc4291_room_ids_as_hashes: true, // Changed from v11
+ strict_event_byte_limits_room_versions: true,
+};
+
+/// Helper class for managing the known room versions, and providing dict-like
+/// access to them for Python.
+///
+/// Note: this is not necessarily all room versions, as we may not want to
+/// support all experimental room versions.
+///
+/// Note: room versions can be added to this mapping at startup (allowing
+/// support for experimental room versions to be behind experimental feature
+/// flags).
+#[pyclass(frozen, mapping)]
+#[derive(Clone)]
+pub struct KnownRoomVersionsMapping {
+ // Note we use a Vec here to ensure that the order of keys is
+ // deterministic. We rely on this when generating parameterized tests in
+ // Python, as Python will generate the tests names including the room
+ // version and index (and we need test names to be stable to e.g. support
+ // running tests in parallel).
+ pub versions: Arc>>,
+}
+
+#[pymethods]
+impl KnownRoomVersionsMapping {
+ /// Add a new room version to the mapping, indicating that this instance
+ /// supports it.
+ fn add_room_version(&self, version: RoomVersion) -> PyResult<()> {
+ let mut versions = self
+ .versions
+ .write()
+ .map_err(|_| PyRuntimeError::new_err("KnownRoomVersionsMapping lock poisoned"))?;
+
+ if versions.iter().any(|v| v.identifier == version.identifier) {
+ // We already have this room version, so we don't add it again (as
+ // otherwise we'd end up with duplicates).
+ return Ok(());
+ }
+
+ versions.push(version);
+ Ok(())
+ }
+
+ fn __getitem__(&self, key: &str) -> PyResult {
+ let versions = self.versions.read().unwrap();
+ versions
+ .iter()
+ .find(|v| v.identifier == key)
+ .copied()
+ .ok_or_else(|| PyKeyError::new_err(key.to_string()))
+ }
+
+ fn __contains__(&self, key: &str) -> PyResult {
+ let versions = self.versions.read().unwrap();
+ Ok(versions.iter().any(|v| v.identifier == key))
+ }
+
+ fn keys(&self) -> PyResult> {
+ // Note that technically we should return a view here (that also acts
+ // like a Set *and* has a stable ordering). We don't depend on this, so
+ // for simplicity we just return a list of the keys.
+ let versions = self.versions.read().unwrap();
+ Ok(versions.iter().map(|v| v.identifier).collect())
+ }
+
+ fn values(&self) -> PyResult> {
+ let versions = self.versions.read().unwrap();
+ Ok(versions.clone())
+ }
+
+ fn items(&self) -> PyResult> {
+ // Note that technically we should return a view here (that also acts
+ // like a Set *and* has a stable ordering). We don't depend on this, so
+ // for simplicity we just return a list of the items.
+ let versions = self.versions.read().unwrap();
+ Ok(versions.iter().map(|v| (v.identifier, *v)).collect())
+ }
+
+ #[pyo3(signature = (key, default=None))]
+ fn get<'py>(
+ &self,
+ py: Python<'py>,
+ key: Bound<'py, PyAny>,
+ default: Option>,
+ ) -> PyResult