diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5e8ad3b18a..127f6bf224 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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -87,7 +87,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: push: true labels: | @@ -183,7 +183,7 @@ jobs: password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 75ceb0f533..bfc5d05c34 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -67,7 +67,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Set up docker layer caching uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/changelog.d/20253.misc b/changelog.d/20253.misc new file mode 100644 index 0000000000..54bf15e0e9 --- /dev/null +++ b/changelog.d/20253.misc @@ -0,0 +1 @@ +Bump `cryptography` from `46.0.7` to `50.0.1` to resolve `CVE-2026-69248`, `CVE-2026-69249` and `CVE-2026-69247`, none of which affected Synapse. Also bump `pyOpenSSL` from `26.0.0` to `26.4.0` for compatibility with newer `cryptography` versions. \ No newline at end of file diff --git a/changelog.d/20286.feature b/changelog.d/20286.feature new file mode 100644 index 0000000000..6331fe13f9 --- /dev/null +++ b/changelog.d/20286.feature @@ -0,0 +1 @@ +Advertise support for Matrix v1.15. diff --git a/changelog.d/20289.bugfix b/changelog.d/20289.bugfix new file mode 100644 index 0000000000..74b7d1f0aa --- /dev/null +++ b/changelog.d/20289.bugfix @@ -0,0 +1 @@ +Fix bug where membership events would not be returned in `/sync` when using lazy-loaded members and using an old token for `since`. diff --git a/poetry.lock b/poetry.lock index 193041e3cf..ae9e819f5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -31,7 +31,7 @@ description = "The ultimate Python library in building OAuth and OpenID Connect optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"oidc\" or extra == \"jwt\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"jwt\" or extra == \"oidc\"" files = [ {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, @@ -62,7 +62,7 @@ description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -454,76 +454,66 @@ files = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "50.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main", "dev"] files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, + {file = "cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527"}, + {file = "cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959"}, + {file = "cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b"}, + {file = "cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648"}, + {file = "cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6"}, + {file = "cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6"}, + {file = "cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149"}, + {file = "cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf"}, + {file = "cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80"}, + {file = "cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558"}, + {file = "cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e"}, + {file = "cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6"}, + {file = "cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b"}, + {file = "cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] [[package]] name = "defusedxml" @@ -532,7 +522,7 @@ description = "XML bomb protection for Python stdlib modules" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, @@ -557,7 +547,7 @@ description = "XPath 1.0/2.0/3.0/3.1 parsers and selectors for ElementTree and l optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "elementpath-4.8.0-py3-none-any.whl", hash = "sha256:5393191f84969bcf8033b05ec4593ef940e58622ea13cefe60ecefbbf09d58d9"}, {file = "elementpath-4.8.0.tar.gz", hash = "sha256:5822a2560d99e2633d95f78694c7ff9646adaa187db520da200a8e9479dc46ae"}, @@ -607,7 +597,7 @@ description = "Python wrapper for hiredis" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"redis\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"redis\"" files = [ {file = "hiredis-3.3.1-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f525734382a47f9828c9d6a1501522c78d5935466d8e2be1a41ba40ca5bb922b"}, {file = "hiredis-3.3.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:6e2e1024f0a021777740cb7c633a0efb2c4a4bc570f508223a8dcbcf79f99ef9"}, @@ -890,7 +880,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -931,7 +921,7 @@ description = "Jaeger Python OpenTracing Tracer implementation" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"opentracing\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"opentracing\"" files = [ {file = "jaeger-client-4.8.0.tar.gz", hash = "sha256:3157836edab8e2c209bd2d6ae61113db36f7ee399e66b1dcbb715d87ab49bfe0"}, ] @@ -1123,7 +1113,7 @@ description = "A strictly RFC 4510 conforming LDAP V3 pure Python client library optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"matrix-synapse-ldap3\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"matrix-synapse-ldap3\"" files = [ {file = "ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70"}, {file = "ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"}, @@ -1240,7 +1230,7 @@ description = "Powerful and Pythonic XML processing library combining libxml2/li optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"url-preview\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"url-preview\"" files = [ {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec"}, {file = "lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec"}, @@ -1548,7 +1538,7 @@ description = "An LDAP3 auth provider for Synapse" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"matrix-synapse-ldap3\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"matrix-synapse-ldap3\"" files = [ {file = "matrix_synapse_ldap3-0.4.0-py3-none-any.whl", hash = "sha256:bf080037230d2af5fd3639cb87266de65c1cad7a68ea206278c5b4bf9c1a17f3"}, {file = "matrix_synapse_ldap3-0.4.0.tar.gz", hash = "sha256:cff52ba780170de5e6e8af42863d2648ee23f3bf0a9fea6db52372f9fc00be2b"}, @@ -1833,7 +1823,7 @@ description = "OpenTracing API for Python. See documentation at http://opentraci optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"opentracing\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"opentracing\"" files = [ {file = "opentracing-2.4.0.tar.gz", hash = "sha256:a173117e6ef580d55874734d1fa7ecb6f3655160b8b8974a2a1e98e5ec9c840d"}, ] @@ -2028,7 +2018,7 @@ description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"postgres\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"postgres\"" files = [ {file = "psycopg2-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:103e857f46bb76908768ead4e2d0ba1d1a130e7b8ed77d3ae91e8b33481813e8"}, {file = "psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb"}, @@ -2046,7 +2036,7 @@ description = ".. image:: https://travis-ci.org/chtd/psycopg2cffi.svg?branch=mas optional = true python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and (extra == \"postgres\" or extra == \"all\")" +markers = "platform_python_implementation == \"PyPy\" and (extra == \"all\" or extra == \"postgres\")" files = [ {file = "psycopg2cffi-2.9.0.tar.gz", hash = "sha256:7e272edcd837de3a1d12b62185eb85c45a19feda9e62fa1b120c54f9e8d35c52"}, ] @@ -2062,7 +2052,7 @@ description = "A Simple library to enable psycopg2 compatability" optional = true python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and (extra == \"postgres\" or extra == \"all\")" +markers = "platform_python_implementation == \"PyPy\" and (extra == \"all\" or extra == \"postgres\")" files = [ {file = "psycopg2cffi-compat-1.1.tar.gz", hash = "sha256:d25e921748475522b33d13420aad5c2831c743227dc1f1f2585e0fdb5c914e05"}, ] @@ -2302,14 +2292,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, - {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, + {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"}, + {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"}, ] [package.dependencies] @@ -2342,7 +2332,7 @@ description = "A development tool to measure, monitor and analyze the memory beh optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"cache-memory\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"cache-memory\"" files = [ {file = "Pympler-1.0.1-py3-none-any.whl", hash = "sha256:d260dda9ae781e1eab6ea15bacb84015849833ba5555f141d2d9b7b7473b307d"}, {file = "Pympler-1.0.1.tar.gz", hash = "sha256:993f1a3599ca3f4fcd7160c7545ad06310c9e12f70174ae7ae8d4e25f6c5d3fa"}, @@ -2392,19 +2382,19 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", " [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.4.0" description = "Python wrapper module around the OpenSSL library" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"}, - {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"}, + {file = "pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c"}, + {file = "pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7"}, ] [package.dependencies] -cryptography = ">=46.0.0,<47" -typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} +cryptography = ">=49.0.0,<51" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] @@ -2474,7 +2464,7 @@ description = "Python implementation of SAML Version 2 Standard" optional = true python-versions = ">=3.9,<4.0" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "pysaml2-7.5.0-py3-none-any.whl", hash = "sha256:bc6627cc344476a83c757f440a73fda1369f13b6fda1b4e16bca63ffbabb5318"}, {file = "pysaml2-7.5.0.tar.gz", hash = "sha256:f36871d4e5ee857c6b85532e942550d2cf90ea4ee943d75eb681044bbc4f54f7"}, @@ -2499,7 +2489,7 @@ description = "Extensions to the standard Python datetime module" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2527,7 +2517,7 @@ description = "World timezone definitions, modern and historical" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, @@ -2931,7 +2921,7 @@ description = "Python client for Sentry (https://sentry.io)" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"sentry\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"sentry\"" files = [ {file = "sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585"}, {file = "sentry_sdk-2.57.0.tar.gz", hash = "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199"}, @@ -3131,7 +3121,7 @@ description = "Tornado IOLoop Backed Concurrent Futures" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"opentracing\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"opentracing\"" files = [ {file = "threadloop-1.0.2-py2-none-any.whl", hash = "sha256:5c90dbefab6ffbdba26afb4829d2a9df8275d13ac7dc58dccb0e279992679599"}, {file = "threadloop-1.0.2.tar.gz", hash = "sha256:8b180aac31013de13c2ad5c834819771992d350267bddb854613ae77ef571944"}, @@ -3147,7 +3137,7 @@ description = "Python bindings for the Apache Thrift RPC system" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"opentracing\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"opentracing\"" files = [ {file = "thrift-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efe85c4508adaf6c9e6f7fac2b1c3c9beb4b39b19c375519966335a70b28e64a"}, {file = "thrift-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2baaec0c5cd7ba3eace54b26d81fdf0f5a85010468687cf4a131871d65abfed"}, @@ -3252,23 +3242,23 @@ markers = {main = "python_version < \"3.11\""} [[package]] name = "tornado" -version = "6.5.7" +version = "6.5.9" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"opentracing\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"opentracing\"" files = [ - {file = "tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163"}, - {file = "tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100"}, - {file = "tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972"}, - {file = "tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b"}, - {file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92"}, - {file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5"}, - {file = "tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4"}, - {file = "tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4"}, - {file = "tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796"}, - {file = "tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2"}, + {file = "tornado-6.5.9-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:dea3bee433b73d6312d993ce89ad5f2b8d3ed9fde56e79ba5342b9edb78d342a"}, + {file = "tornado-6.5.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:22bed49cdb55292d1d58f46008ba2cc3020e5b84995ef6ae749676c8b5309514"}, + {file = "tornado-6.5.9-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cc0876a58eee9143476f24901b1b8eb4d52443283ade2b6bca650dd1da04f84"}, + {file = "tornado-6.5.9-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc0d703cc7ec92b47ebfe236c97cd765c1f2e45744a2e156e6677e54c948274d"}, + {file = "tornado-6.5.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:93f9ab0226ef625b94cacb7981a5af104f1735ea02f92282abe5c389109fb31d"}, + {file = "tornado-6.5.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5801bbf94dfde8d21087b6aa0b17f3a5dab6110dfe44e7e4492df5de393ac15b"}, + {file = "tornado-6.5.9-cp39-abi3-win32.whl", hash = "sha256:dca9f377e80efe5dd4d52a2776b67492a81a39b2d931de169b6b36d0c1da444c"}, + {file = "tornado-6.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:f810730ac71eadf009e6e5cb6d534d096887310f5649cff19d7e0d19c0bf4ee7"}, + {file = "tornado-6.5.9-cp39-abi3-win_arm64.whl", hash = "sha256:b5f4d92798337260c6d9f9f0e400c7539dd5e55cf93479a3f8508c9aa09c2b3b"}, + {file = "tornado-6.5.9.tar.gz", hash = "sha256:4d868544ebdf2fc155239a74397f65ebfea9b4d3635296c96b5dfb0701c354f5"}, ] [[package]] @@ -3334,7 +3324,7 @@ keyring = {version = ">=21.2.0", markers = "platform_machine != \"ppc64le\" and packaging = ">=24.0" readme-renderer = ">=35.0" requests = ">=2.20" -requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +requests-toolbelt = ">=0.8.0,!=0.9.0" rfc3986 = ">=1.4.0" rich = ">=12.0.0" urllib3 = ">=1.26.0" @@ -3389,7 +3379,7 @@ description = "non-blocking redis client for python" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"redis\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"redis\"" files = [ {file = "txredisapi-1.4.12-py3-none-any.whl", hash = "sha256:c698fff24a0b82e8932ef18c5623496b0a8b5195e4e94bf8524800a00ca6b7c1"}, {file = "txredisapi-1.4.12.tar.gz", hash = "sha256:98e2440ff2e297c9048c5f9d516e7b1302d447cd9cd4568c986c35a8523de3c7"}, @@ -3615,14 +3605,14 @@ files = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, - {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] [package.extras] @@ -3650,7 +3640,7 @@ description = "An XML Schema validator and decoder" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"saml2\" or extra == \"all\"" +markers = "extra == \"all\" or extra == \"saml2\"" files = [ {file = "xmlschema-2.5.1-py3-none-any.whl", hash = "sha256:ec2b2a15c8896c1fcd14dcee34ca30032b99456c3c43ce793fdb9dca2fb4b869"}, {file = "xmlschema-2.5.1.tar.gz", hash = "sha256:4f7497de6c8b6dc2c28ad7b9ed6e21d186f4afe248a5bea4f54eedab4da44083"}, @@ -3671,7 +3661,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, diff --git a/rust/src/handlers/versions.rs b/rust/src/handlers/versions.rs index 0ac24de951..e3419d5020 100644 --- a/rust/src/handlers/versions.rs +++ b/rust/src/handlers/versions.rs @@ -158,6 +158,7 @@ async fn build_versions_response( "v1.12".to_string(), "v1.13".to_string(), "v1.14".to_string(), + "v1.15".to_string(), ]), unstable_features: unstable_feature_map, }) diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py index a3ebf8bb1d..2eb5eb79c6 100644 --- a/synapse/handlers/sync.py +++ b/synapse/handlers/sync.py @@ -23,6 +23,7 @@ import itertools import json import logging import os +from collections import OrderedDict from typing import ( TYPE_CHECKING, AbstractSet, @@ -74,6 +75,7 @@ from synapse.types import ( MutableStateMap, Requester, RoomStreamToken, + StateKey, StateMap, StrCollection, StreamKeyType, @@ -130,6 +132,96 @@ LAZY_LOADED_PROFILE_FIELDS_CACHE_DIGEST_SIZE = 16 LAZY_LOADED_PROFILE_FIELDS_CACHE_DIGEST_KEY = os.urandom(32) +@attr.s(slots=True, auto_attribs=True) +class LazyLoadedMembersCache: + """The membership events a lazy-loading client has been sent, so that later + syncs can leave them out. One per (user, device). + + A member may only be left out if the client processed the response that + carried it. Each entry therefore records the `next_batch` position of the + response that carried it. A client that has processed a response syncs + from its `next_batch` next, so a request from `since` means the client + processed every response up to `since` and none after it. `start_request` + forgets the entries recorded after `since`. They were in responses the + client lost, by timing out a slow request and retrying it (#19978) or by + reloading from an older persisted token (#20278). Entries recorded at or + before `since` stay, so a client resuming from a token a few minutes old is + only sent again what it lost in those minutes. An initial sync forgets + everything. + + Forgetting everything whenever `since` is before the newest entry would be + simpler, and `_newest` alone would suffice. But Element Web only persists + its sync token every few minutes and resumes from it on every reload, so + that would re-send every cached member on each reload. + + The notifier may build several results for one long-poll and returns only + the first non-empty one. An empty result carries no members, so it records + nothing. + + Leaving out a member the client never received loses it for good. A + `state_after` client does not apply state from the timeline, and a `state` + client only sees the membership changes that fall inside the timeline. + """ + + # user_id -> (event_id of the membership we last sent, room position of the + # `next_batch` of the response that carried it). Least recently used first. + # + # Not an `LruCache`, as that cannot be iterated over and `start_request` + # needs to check every entry's position against `since`. + _sent: OrderedDict[str, tuple[str, RoomStreamToken]] = attr.Factory(OrderedDict) + + # An upper bound on the positions of the entries, so that `start_request` + # need not look at them when nothing was recorded after `since`. That is the + # steady state, where `since` is the `next_batch` of the last response. + _newest: RoomStreamToken | None = None + + def start_request(self, since_token: StreamToken | None) -> None: + """Forget the members the client did not receive. Called at the start of + each sync request for this device.""" + if since_token is None: + self._sent.clear() + self._newest = None + return + + # Check for the common case where nothing has been recorded after + # `since`. + since = since_token.room_key + if self._newest is None or self._newest.is_before_or_eq(since): + return + + # Remove all entries that were sent after `since`. This only has + # `LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE` entries. + for user_id, (_, sent_at) in list(self._sent.items()): + if not sent_at.is_before_or_eq(since): + del self._sent[user_id] + + # Everything left is at or before `since`. + self._newest = since + + def was_sent(self, user_id: str, event_id: str) -> bool: + """Whether `event_id` is the membership of `user_id` we last sent.""" + entry = self._sent.get(user_id) + if entry is None or entry[0] != event_id: + return False + self._sent.move_to_end(user_id) + return True + + def mark_sent(self, user_id: str, event_id: str, next_batch: StreamToken) -> None: + """Record that `event_id` is being sent in the response ending at + `next_batch`.""" + sent_at = next_batch.room_key + self._sent[user_id] = (event_id, sent_at) + self._sent.move_to_end(user_id) + while len(self._sent) > LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE: + self._sent.popitem(last=False) + + # Only move the bound forwards. Positions taken by this process only + # ever advance, but a response that started earlier may record its + # entries after a later one has. + if self._newest is None or self._newest.is_before_or_eq(sent_at): + self._newest = sent_at + + SyncRequestKey = tuple[Any, ...] @@ -345,9 +437,9 @@ class SyncHandler: timeout=hs.config.caches.sync_response_cache_duration, ) - # ExpiringCache((User, Device)) -> LruCache(user_id => event_id) + # ExpiringCache((User, Device)) -> LazyLoadedMembersCache self.lazy_loaded_members_cache: ExpiringCache[ - tuple[str, str | None], LruCache[str, str] + tuple[str, str | None], LazyLoadedMembersCache ] = ExpiringCache( cache_name="lazy_loaded_members_cache", server_name=self.server_name, @@ -1088,7 +1180,7 @@ class SyncHandler: member_ids[hero_id] for hero_id in summary["m.heroes"] if ( - cache.get(hero_id) != member_ids[hero_id] + not cache.was_sent(hero_id, member_ids[hero_id]) and hero_id not in existing_members ) ] @@ -1096,27 +1188,21 @@ class SyncHandler: missing_hero_state = await self.store.get_events(missing_hero_event_ids) for s in missing_hero_state.values(): - cache.set(s.state_key, s.event_id) + cache.mark_sent(s.state_key, s.event_id, now_token) state[(EventTypes.Member, s.state_key)] = s return summary def get_lazy_loaded_members_cache( self, cache_key: tuple[str, str | None] - ) -> LruCache[str, str]: - # FIXME: This cache may be subject to losing members in the case that - # a sync is interrupted and retried, see https://github.com/element-hq/synapse/issues/19978 - cache: LruCache[str, str] | None = self.lazy_loaded_members_cache.get(cache_key) + ) -> LazyLoadedMembersCache: + cache = self.lazy_loaded_members_cache.get(cache_key) if cache is None: - logger.debug("creating LruCache for %r", cache_key) - cache = LruCache( - max_size=LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE, - clock=self.clock, - server_name=self.server_name, - ) + logger.debug("creating LazyLoadedMembersCache for %r", cache_key) + cache = LazyLoadedMembersCache() self.lazy_loaded_members_cache[cache_key] = cache else: - logger.debug("found LruCache for %r", cache_key) + logger.debug("found LazyLoadedMembersCache for %r", cache_key) return cache def get_lazy_loaded_profile_fields_cache( @@ -1155,6 +1241,7 @@ class SyncHandler: sync_config: SyncConfig, since_token: StreamToken | None, end_token: StreamToken, + now_token: StreamToken, full_state: bool, joined: bool, ) -> MutableStateMap[EventBase]: @@ -1169,6 +1256,7 @@ class SyncHandler: end_token: Token of the end of the current batch. Normally this will be the same as the global "now_token", but if the user has left the room, the point just after their leave event. + now_token: The `next_batch` token of the response being built. full_state: Whether to force returning the full state. `lazy_load_members` still applies when `full_state` is `True`. joined: whether the user is currently joined to the room @@ -1254,6 +1342,11 @@ class SyncHandler: # sync's timeline and the start of the current sync's timeline. # See the docstring above for details. state_ids: StateMap[str] + # The keys in `state_ids` whose state changed since `since_token`. + # These are sent whether or not the lazy-loaded members cache says + # the client has them. The rest are memberships fetched for + # timeline senders, which the client may already have. + changed_keys: set[StateKey] = set() # We need to know whether the state we fetch may be partial, so check # whether the room is partial stated *before* fetching it. is_partial_state_room = await self.store.is_partial_state_room(room_id) @@ -1273,7 +1366,10 @@ class SyncHandler: # is indeed the case. assert since_token is not None - state_ids = await self._compute_state_delta_for_incremental_sync( + ( + state_ids, + changed_keys, + ) = await self._compute_state_delta_for_incremental_sync( room_id, sync_config, batch, @@ -1309,30 +1405,28 @@ class SyncHandler: cache_key = (sync_config.user.to_string(), sync_config.device_id) cache = self.get_lazy_loaded_members_cache(cache_key) - # if it's a new sync sequence, then assume the client has had - # amnesia and doesn't want any recent lazy-loaded members - # de-duplicated. - if since_token is None: - logger.debug("clearing LruCache for %r", cache_key) - cache.clear() - else: - # only send members which aren't in our LruCache (either - # because they're new to this client or have been pushed out - # of the cache) - logger.debug("filtering state from %r...", state_ids) - state_ids = { - t: event_id - for t, event_id in state_ids.items() - if cache.get(t[1]) != event_id - } - logger.debug("...to %r", state_ids) + # Only send members which aren't in our cache (either because + # they're new to this client or have been pushed out of the + # cache). For an initial sync the cache has already been + # cleared. + # + # A state change since `since_token` is always sent, regardless + # of whether it was previously sent to the client (e.g due to a + # client retrying the request). + logger.debug("filtering state from %r...", state_ids) + state_ids = { + t: event_id + for t, event_id in state_ids.items() + if t in changed_keys or not cache.was_sent(t[1], event_id) + } + logger.debug("...to %r", state_ids) - # add any member IDs we are about to send into our LruCache + # add any member IDs we are about to send into our cache for t, event_id in itertools.chain( state_ids.items(), timeline_state.items() ): if t[0] == EventTypes.Member: - cache.set(t[1], event_id) + cache.mark_sent(t[1], event_id, now_token) state: dict[str, EventBase] = {} if state_ids: @@ -1505,14 +1599,16 @@ class SyncHandler: members_to_fetch: set[str] | None, timeline_state: StateMap[str], joined: bool, - ) -> StateMap[str]: + ) -> tuple[StateMap[str], set[StateKey]]: """Calculate the state events to be included in an incremental sync response. If lazy-loading of membership events is enabled (as indicated by `members_to_fetch` being not-`None`), the result will include the membership events for each member in `members_to_fetch`. The caller (`compute_state_delta`) is responsible for keeping track of which membership - events we have already sent to the client, and hence ripping them out. + events we have already sent to the client, and hence ripping them out. It + may only rip out entries that are not state changes, so this also returns + the keys whose state changed since `since_token`. Note that whether this returns the state at the start or the end of the batch depends on `sync_config.use_state_after` (c.f. MSC4222). @@ -1533,7 +1629,10 @@ class SyncHandler: Returns: A map from (type, state_key) to event_id, for each event that we believe - should be included in the `state` or `state_after` part of the sync response. + should be included in the `state` or `state_after` part of the sync + response, and the subset of its keys whose state changed since + `since_token`. The other keys are memberships fetched for timeline + senders, which the client may have been sent before. """ if members_to_fetch is not None: # Lazy-loading is enabled. Only return the state that is needed. @@ -1549,6 +1648,7 @@ class SyncHandler: # timeline. If at the end we can just use the current state delta stream. if sync_config.use_state_after: delta_state_ids: MutableStateMap[str] = {} + changed_keys: set[StateKey] = set() if members_to_fetch: # We're lazy-loading, so the client might need some more member @@ -1603,9 +1703,11 @@ class SyncHandler: # Note that deltas are in stream ordering, so if there are # multiple deltas for a given type/state_key we'll always pick # the latest one. - delta_state_ids[(delta.event_type, delta.state_key)] = delta.event_id + key = (delta.event_type, delta.state_key) + delta_state_ids[key] = delta.event_id + changed_keys.add(key) - return delta_state_ids + return delta_state_ids, changed_keys # For a non-gappy sync if the events in the timeline are simply a linear # chain (i.e. no merging/branching of the graph), then we know the state @@ -1653,7 +1755,9 @@ class SyncHandler: await_full_state=False, ) ) - return state_ids + # A linear, non-gappy timeline means the only changes to the `state` + # are lazy-loaded members. + return state_ids, set() if batch: state_at_timeline_start = ( @@ -1712,7 +1816,16 @@ class SyncHandler: lazy_load_members=lazy_load_members, ) - return state_ids + # Everything that differs from the state at the previous sync is a + # change. What is left is the memberships of timeline senders that + # `_calculate_state` adds back for lazy loading. + changed_keys = { + key + for key, event_id in state_ids.items() + if state_at_previous_sync.get(key) != event_id + } + + return state_ids, changed_keys async def _find_missing_partial_state_memberships( self, @@ -2650,7 +2763,13 @@ class SyncHandler: """ since_token = sync_result_builder.since_token - user_id = sync_result_builder.sync_config.user.to_string() + sync_config = sync_result_builder.sync_config + user_id = sync_config.user.to_string() + + if sync_config.filter_collection.lazy_load_members(): + self.get_lazy_loaded_members_cache( + (user_id, sync_config.device_id) + ).start_request(since_token) blocks_all_rooms = ( sync_result_builder.sync_config.filter_collection.blocks_all_rooms() @@ -3318,6 +3437,7 @@ class SyncHandler: sync_config, since_token, room_builder.end_token, + now_token, full_state=full_state, joined=room_builder.rtype == "joined", ) diff --git a/tests/handlers/test_sync.py b/tests/handlers/test_sync.py index 2f0107dd0a..1002a0374c 100644 --- a/tests/handlers/test_sync.py +++ b/tests/handlers/test_sync.py @@ -33,7 +33,9 @@ from synapse.api.room_versions import RoomVersion, RoomVersions from synapse.events import EventBase from synapse.events.snapshot import EventContext from synapse.handlers.sync import ( + LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE, LAZY_LOADED_PROFILE_FIELDS_CACHE_MAX_AGE, + LazyLoadedMembersCache, SyncConfig, SyncRequestKey, SyncResult, @@ -48,6 +50,7 @@ from synapse.types import ( MultiWriterStreamToken, RoomStreamToken, StreamKeyType, + StreamToken, UserID, create_requester, ) @@ -2947,7 +2950,7 @@ class SyncStateAfterTestCase(tests.unittest.HomeserverTestCase): # Calculating the incrementals state will return the second state, and not the # first. - state = self.get_success( + state, _ = self.get_success( self.sync_handler._compute_state_delta_for_incremental_sync( room_id=joined_room, sync_config=generate_sync_config(user, use_state_after=True), @@ -2979,7 +2982,7 @@ class SyncStateAfterTestCase(tests.unittest.HomeserverTestCase): since_token = self.hs.get_event_sources().get_current_token() end_stream_token = self.hs.get_event_sources().get_current_token() - state = self.get_success( + state, _ = self.get_success( self.sync_handler._compute_state_delta_for_incremental_sync( room_id=joined_room, sync_config=generate_sync_config(user, use_state_after=True), @@ -2995,3 +2998,76 @@ class SyncStateAfterTestCase(tests.unittest.HomeserverTestCase): ) self.assertEqual(state, {}) + + +class LazyLoadedMembersCacheTestCase(tests.unittest.TestCase): + """Direct tests of the per-device cache of lazy-loaded members sent.""" + + @staticmethod + def _token(stream: int) -> StreamToken: + """A token whose room position is `stream`. Only the room position + matters to the cache.""" + return StreamToken.START.copy_and_replace( + StreamKeyType.ROOM, RoomStreamToken(stream=stream) + ) + + def test_forgets_only_entries_after_since(self) -> None: + """A request from `since` forgets the entries recorded after it and + keeps the rest.""" + cache = LazyLoadedMembersCache() + cache.mark_sent("@a:test", "$a", self._token(5)) + cache.mark_sent("@b:test", "$b", self._token(7)) + + # The client synced past both responses. + cache.start_request(self._token(7)) + self.assertTrue(cache.was_sent("@a:test", "$a")) + self.assertTrue(cache.was_sent("@b:test", "$b")) + + # The client lost the response that ended at 7. + cache.start_request(self._token(6)) + self.assertTrue(cache.was_sent("@a:test", "$a")) + self.assertFalse(cache.was_sent("@b:test", "$b")) + + # A later request from an even older token forgets the rest. + cache.start_request(self._token(4)) + self.assertFalse(cache.was_sent("@a:test", "$a")) + + def test_initial_sync_forgets_everything(self) -> None: + """An initial sync has no `since`, and the client starts from nothing.""" + cache = LazyLoadedMembersCache() + cache.mark_sent("@a:test", "$a", self._token(5)) + cache.start_request(None) + self.assertFalse(cache.was_sent("@a:test", "$a")) + + def test_different_event_is_not_sent(self) -> None: + """An entry only covers the membership event it was recorded with, so + a changed membership counts as not sent.""" + cache = LazyLoadedMembersCache() + cache.mark_sent("@a:test", "$a1", self._token(5)) + self.assertFalse(cache.was_sent("@a:test", "$a2")) + + def test_out_of_order_marks_are_still_forgotten(self) -> None: + """A response that started earlier can record its entries after a + later one. Its entries must still be forgotten by a request from + before either response.""" + cache = LazyLoadedMembersCache() + cache.mark_sent("@b:test", "$b", self._token(7)) + cache.mark_sent("@a:test", "$a", self._token(5)) + + cache.start_request(self._token(6)) + self.assertTrue(cache.was_sent("@a:test", "$a")) + self.assertFalse(cache.was_sent("@b:test", "$b")) + + def test_evicts_least_recently_used(self) -> None: + """The cache holds `LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE` entries and + evicts the least recently used one. A hit counts as a use.""" + cache = LazyLoadedMembersCache() + for i in range(LAZY_LOADED_MEMBERS_CACHE_MAX_SIZE): + cache.mark_sent(f"@u{i}:test", f"$u{i}", self._token(1)) + # Touch the oldest entry so that it is no longer the eviction candidate. + self.assertTrue(cache.was_sent("@u0:test", "$u0")) + + cache.mark_sent("@new:test", "$new", self._token(1)) + self.assertTrue(cache.was_sent("@u0:test", "$u0")) + self.assertFalse(cache.was_sent("@u1:test", "$u1")) + self.assertTrue(cache.was_sent("@new:test", "$new")) diff --git a/tests/rest/client/test_sync.py b/tests/rest/client/test_sync.py index 51ffabe75d..20fdacdc01 100644 --- a/tests/rest/client/test_sync.py +++ b/tests/rest/client/test_sync.py @@ -45,7 +45,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 +from tests.server import FakeChannel, TimedOutException from tests.test_utils.event_injection import ( inject_event, persist_message_and_state_event_in_one_batch, @@ -1684,3 +1684,334 @@ class SyncStateAfterArchivedRoomTestCase(unittest.HomeserverTestCase): ], [bob_member_event_id_at_leave], ) + + +class SyncLazyLoadedMembersCacheTestCase(unittest.HomeserverTestCase): + """Tests for the per-device cache of lazy-loaded members already sent. + + A membership left out of a response as already sent is lost for good if + the client never processed the response that carried it. The cache must + only trust a response once the client has synced from its `next_batch`. + """ + + servlets = [ + synapse.rest.admin.register_servlets, + room.register_servlets, + login.register_servlets, + sync.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.alice = self.register_user("alice", "password") + self.alice_device = "alice_device" + self.alice_tok = self.login("alice", "password", device_id=self.alice_device) + self.bob = self.register_user("bob", "password") + self.bob_tok = self.login("bob", "password") + + self.get_success( + hs.get_datastores().main.set_features_for_user( + self.alice, {ExperimentalFeature.MSC4222: True} + ) + ) + + # Named room, so that hero calculation doesn't inject current + # membership state into the response and confuse the assertions. + self.room_id = self.helper.create_room_as( + self.alice, tok=self.alice_tok, extra_content={"name": "test room"} + ) + self.helper.join(self.room_id, self.bob, tok=self.bob_tok) + + # Push bob's join out of the initial sync's timeline, so that his + # membership has not been sent to the client when a test starts. + for i in range(10): + self.helper.send(self.room_id, body=f"pad {i}", tok=self.alice_tok) + + def _sync_request( + self, + use_state_after: bool, + since: str | None = None, + *, + timeline_limit: int = 10, + timeline_not_types: list[str] | None = None, + timeout_ms: int = 0, + await_result: bool = True, + ) -> FakeChannel: + """Make a lazy-loading `/sync` request as alice.""" + timeline_filter: JsonDict = {"limit": timeline_limit} + if timeline_not_types is not None: + timeline_filter["not_types"] = timeline_not_types + sync_filter: JsonDict = { + "room": { + "timeline": timeline_filter, + "state": {"lazy_load_members": True}, + } + } + url = f"/sync?filter={json.dumps(sync_filter)}&timeout={timeout_ms}" + if use_state_after: + url += "&org.matrix.msc4222.use_state_after=true" + if since is not None: + url += f"&since={since}" + return self.make_request( + "GET", url, access_token=self.alice_tok, await_result=await_result + ) + + def _sync( + self, + use_state_after: bool, + since: str | None = None, + *, + timeline_limit: int = 10, + timeline_not_types: list[str] | None = None, + ) -> JsonDict: + """Sync as alice and return the response body.""" + channel = self._sync_request( + use_state_after, + since, + timeline_limit=timeline_limit, + timeline_not_types=timeline_not_types, + ) + self.assertEqual(channel.code, 200, channel.result) + return channel.json_body + + def _room(self, response: JsonDict) -> JsonDict: + """Extract the room dict for the test room from a sync response.""" + rooms = response["rooms"].get("join", {}) + self.assertIn(self.room_id, rooms, f"room missing from sync: {response}") + return rooms[self.room_id] + + def _timeline_ids(self, response: JsonDict) -> list[str]: + return [e["event_id"] for e in self._room(response)["timeline"]["events"]] + + def _state_events( + self, response: JsonDict, use_state_after: bool + ) -> list[JsonDict]: + """Extract the list of state events from the test room in a sync response.""" + key = "org.matrix.msc4222.state_after" if use_state_after else "state" + return self._room(response)[key]["events"] + + def _state_ids(self, response: JsonDict, use_state_after: bool) -> list[str]: + """Extract the list of state event IDs from the test room in a sync + response.""" + return [e["event_id"] for e in self._state_events(response, use_state_after)] + + def _members_in_state(self, response: JsonDict, use_state_after: bool) -> list[str]: + """Extract the list of member state keys from the test room in a sync + response.""" + return [ + e["state_key"] + for e in self._state_events(response, use_state_after) + if e["type"] == EventTypes.Member + ] + + @parameterized.expand([("state", False), ("state_after", True)]) + def test_member_sent_once_when_acknowledged( + self, _: str, use_state_after: bool + ) -> None: + """A lazy-loaded member is sent the first time it is needed and left + out once the client has synced past the response that carried it.""" + t0 = self._sync(use_state_after)["next_batch"] + + self.helper.send(self.room_id, body="one", tok=self.bob_tok) + response = self._sync(use_state_after, t0) + self.assertEqual(self._members_in_state(response, use_state_after), [self.bob]) + t1 = response["next_batch"] + + self.helper.send(self.room_id, body="two", tok=self.bob_tok) + response = self._sync(use_state_after, t1) + self.assertEqual(self._members_in_state(response, use_state_after), []) + + @parameterized.expand([("state", False), ("state_after", True)]) + def test_member_not_resent_on_resync_from_token_that_carried_it( + self, _: str, use_state_after: bool + ) -> None: + """A client that resumes from an older token is only sent again the + members carried by the responses after that token. + + A member sent in the response that ended at the resumed-from token was + processed by the client before it saved that token, so it must not be + sent again. matrix-js-sdk saves its sync store every few minutes and + resumes from the saved token on reload, so this is the common case. + """ + t0 = self._sync(use_state_after)["next_batch"] + + # bob's membership is carried by the response that ends at `t1`. + self.helper.send(self.room_id, body="one", tok=self.bob_tok) + response = self._sync(use_state_after, t0) + self.assertEqual(self._members_in_state(response, use_state_after), [self.bob]) + t1 = response["next_batch"] + + # The client processes further responses, then reloads from `t1`. + self.helper.send(self.room_id, body="two", tok=self.bob_tok) + self._sync(use_state_after, t1) + self.helper.send(self.room_id, body="three", tok=self.bob_tok) + response = self._sync(use_state_after, t1) + self.assertEqual( + self._members_in_state(response, use_state_after), + [], + f"bob's membership re-sent on resume from the token that carried " + f"it: {response}", + ) + + @parameterized.expand([("state", False), ("state_after", True)]) + def test_member_resent_on_retry_from_same_token( + self, _: str, use_state_after: bool + ) -> None: + """A client that gives up on a slow request and retries it must get the + lazy-loaded members again. It never processed the first response.""" + t0 = self._sync(use_state_after)["next_batch"] + + self.helper.send(self.room_id, body="hello", tok=self.bob_tok) + for attempt in range(2): + response = self._sync(use_state_after, t0) + self.assertEqual( + self._members_in_state(response, use_state_after), + [self.bob], + f"bob's membership missing on attempt {attempt}: {response}", + ) + + def test_membership_change_resent_on_resync_from_older_token(self) -> None: + """A membership change must be in `state_after` every time the sync + window covers it, even if it has been sent before. + + Clients do not persist every `since` token. matrix-js-sdk saves its + sync store every few minutes, so after a reload it resumes from a token + older than the one the change was first delivered on. A `state_after` + client never applies state from the timeline, so if the change is left + out of `state_after` it keeps the stale membership until it clears its + cache. A kicked user stays shown as joined. + """ + t0 = self._sync(use_state_after=True)["next_batch"] + + self.helper.send(self.room_id, body="hello", tok=self.bob_tok) + t1 = self._sync(use_state_after=True, since=t0)["next_batch"] + + kick = self.helper.change_membership( + self.room_id, self.alice, self.bob, "leave", tok=self.alice_tok + ) + + # The live client sees the kick. + response = self._sync(use_state_after=True, since=t1) + self.assertIn(kick["event_id"], self._state_ids(response, use_state_after=True)) + + # The client reloads from the older persisted token. + response = self._sync(use_state_after=True, since=t0) + self.assertIn(kick["event_id"], self._timeline_ids(response)) + self.assertIn( + kick["event_id"], + self._state_ids(response, use_state_after=True), + f"membership change missing from state_after on re-sync from an " + f"older token: {response}", + ) + + def test_gappy_membership_change_resent_on_resync_from_older_token( + self, + ) -> None: + """The same for `state`. A membership change that falls in the gap of + a limited timeline is only conveyed by `state`, so it must be there + every time the sync window covers it.""" + t0 = self._sync(use_state_after=False, timeline_limit=1)["next_batch"] + + self.helper.send(self.room_id, body="hello", tok=self.bob_tok) + t1 = self._sync(use_state_after=False, since=t0, timeline_limit=1)["next_batch"] + + kick = self.helper.change_membership( + self.room_id, self.alice, self.bob, "leave", tok=self.alice_tok + ) + # A later message, so that the kick falls into the gap of the + # one-event timeline. + self.helper.send(self.room_id, body="after", tok=self.alice_tok) + + # The live client sees the kick in `state`. + response = self._sync(use_state_after=False, since=t1, timeline_limit=1) + self.assertTrue(self._room(response)["timeline"]["limited"]) + self.assertNotIn(kick["event_id"], self._timeline_ids(response)) + self.assertIn( + kick["event_id"], self._state_ids(response, use_state_after=False) + ) + + # The client reloads from the older persisted token. + response = self._sync(use_state_after=False, since=t0, timeline_limit=1) + self.assertNotIn(kick["event_id"], self._timeline_ids(response)) + self.assertIn( + kick["event_id"], + self._state_ids(response, use_state_after=False), + f"membership change missing from state on re-sync from an older " + f"token: {response}", + ) + + @parameterized.expand([("state", False), ("state_after", True)]) + def test_member_not_resent_after_empty_long_poll_wakeup( + self, _: str, use_state_after: bool + ) -> None: + """A long-poll that wakes up with nothing to send keeps waiting. The + response it eventually returns must still leave out the members the + client already has. + + The notifier calls back into the sync handler on every wakeup within + one request. An empty result is not a response the client lost. + """ + not_types = ["m.spurious"] + t0 = self._sync(use_state_after, timeline_not_types=not_types)["next_batch"] + + self.helper.send(self.room_id, body="one", tok=self.bob_tok) + response = self._sync(use_state_after, t0, timeline_not_types=not_types) + self.assertEqual(self._members_in_state(response, use_state_after), [self.bob]) + t1 = response["next_batch"] + + channel = self._sync_request( + use_state_after, + t1, + timeline_not_types=not_types, + timeout_ms=30000, + await_result=False, + ) + + # Wakes the long-poll, but the filter drops the event, so the result is + # empty and the notifier waits again. + self.helper.send_event( + self.room_id, type="m.spurious", content={}, tok=self.bob_tok + ) + self.assertFalse(channel.is_finished()) + + self.helper.send(self.room_id, body="two", tok=self.bob_tok) + channel.await_result() + self.assertEqual(channel.code, 200, channel.result) + response = channel.json_body + self.assertEqual(len(self._timeline_ids(response)), 1) + self.assertEqual(self._members_in_state(response, use_state_after), []) + + @parameterized.expand([("state", False), ("state_after", True)]) + def test_membership_change_sent_when_cache_claims_it_was( + self, _: str, use_state_after: bool + ) -> None: + """A membership change since `since` is sent even when the cache says + the client already has it. + + The cache only decides whether to leave out the memberships fetched for + timeline senders. It can be wrong: a retry of a request can run while + the original is still being built, and the original then marks members + the retry has to send. A change must never depend on it. + """ + t0 = self._sync(use_state_after, timeline_limit=1)["next_batch"] + + kick = self.helper.change_membership( + self.room_id, self.alice, self.bob, "leave", tok=self.alice_tok + ) + # A later message, so that the kick falls into the gap of the + # one-event timeline and only `state` can carry it. + self.helper.send(self.room_id, body="after the kick", tok=self.alice_tok) + + # Claim the kick was already sent in the response that ended at `t0`. + # Syncing from `t0` does not forget this, as the client has acknowledged + # that response. + t0_token = self.get_success( + StreamToken.from_string(self.hs.get_datastores().main, t0) + ) + cache = self.hs.get_sync_handler().get_lazy_loaded_members_cache( + (self.alice, self.alice_device) + ) + cache.mark_sent(self.bob, kick["event_id"], t0_token) + + response = self._sync(use_state_after, t0, timeline_limit=1) + self.assertNotIn(kick["event_id"], self._timeline_ids(response)) + self.assertIn(kick["event_id"], self._state_ids(response, use_state_after))