element-hq / element-hq/element-web
QR login: existing device gets 412 "ETag does not match" on rendezvous PUT and does not retry
- Dominant language
- TypeScript
- Stars
- 13.5k
- Forks
- 2.8k
- PR merge metrics
- PR metrics pending
Description
### Steps to reproduce
1. Self-hosted Synapse with MAS (OAuth delegation), `experimental_features: msc4108_enabled: true`
2. Element Web → Settings → Sessions → "Link new device"
3. Scan the QR code with Element X Android
### Outcome
**Expected:** the device is linked.
**What happened:** Element X stays on "Establishing a secure connection" until the
login attempt expires ("The linking wasn't completed in the required time").
Reproducible over both Wi-Fi and mobile data.
### Server-side log
The rendezvous channel opens, the new device writes successfully, then the
existing device fails to write and never recovers:
```
17:47:12,545 ElementX GET 200 0B /_synapse/client/rendezvous/01KYMPH0...
17:47:12,682 ElementX PUT 202 /_synapse/client/rendezvous/01KYMPH0...
17:47:12,765 ElementX GET 304 0B /_synapse/client/rendezvous/01KYMPH0...
17:47:13,509 Firefox GET 200 104B /_synapse/client/rendezvous/01KYMPH0...
17:47:13,547 Firefox PUT 412 103B /_synapse/client/rendezvous/01KYMPH0...
SynapseError: 412 - ETag does not match
17:47:13,583 Firefox GET 304 0B /_synapse/client/rendezvous/01KYMPH0...
[both sides poll with 304 for ~53s, no further PUT from either side]
17:48:06,015 ElementX GET 404 (session expired)
```
Two things stand out:
1. Element Web reads the payload (200, 104B) and 38 ms later sends a PUT whose
`If-Match` does not match — yet its very next GET returns 304, so it *does*
hold the current ETag.
2. After the 412 it never retries. In this protocol 412 is the normal
"someone else wrote first, re-read and try again" response, so giving up
here deadlocks the handshake.
The same pattern repeats in every attempt: the device that reads an empty
session writes fine; the device that reads a non-empty payload gets 412.
### Server side ruled out
`org.matrix.msc4108: true` in `/_matrix/client/versions`. Manually replaying the
exact operation against the same endpoint through the same reverse proxy
succeeds, with a payload larger than the 104 bytes Element Web chokes on:
```console
$ URL=$(curl -s -D h -X POST -H 'Content-Type: text/plain' --data 'x' \
https://$SERVER/_matrix/client/unstable/org.matrix.msc4108/rendezvous | jq -r .url)
$ ET=$(grep -i '^etag:' h | tr -d '\r' | sed 's/^[^:]*: //')
$ curl -s -o /dev/null -w 'PUT %{http_code}\n' -X PUT -H 'Content-Type: text/plain' \
-H "If-Match: $ET" --data "$(printf 'a%.0s' {1..200})" "$URL"
PUT 202
$ curl -si -H 'Accept-Encoding: gzip, deflate, br' "$URL" | grep -iE '^(etag|content-encoding)'
etag: "wqkI2Y9d-Yet5BtfziEwZ--8wh7yJAISpB5UtefCiuU"
```
Strong ETag, no `Content-Encoding` despite the client offering gzip/br — the
proxy rewrites neither the ETag nor the body. Compression-weakened ETags
(`W/"..."`, which would legitimately fail `If-Match` under RFC 9110) are
therefore not the cause. Reverse proxy has `gzip off`, `proxy_buffering off`
and `proxy_request_buffering off` on both rendezvous paths.
Possibly related: matrix-org/matrix-js-sdk#5186 (supporting both the 2024 and
2025 revisions of MSC4108) — if Element Web and the Rust SDK in Element X
negotiate different revisions of the secure channel, that would explain the
ETag mismatch.
### Versions
- Element Web: 1.12.24 (Firefox 153, Linux/Windows)
- Element X Android: 26.07.1 (Google Pixel Fold, Android 17)
- Synapse: 1.157.2
- MAS: 1.21.0
- Deployment: self-hosted, nginx reverse proxy
### Operating system
_No response_
### Browser information
_No response_
### URL for webapp
1.12.24
### Application version
Element X Android: 26.07.1
### Homeserver
1.157.2
### Will you send logs?
Yes
Contributor guide
Research direction
Start by tracing Element Web's rendezvous PUT and ETag handling, then compare its MSC4108 revision negotiation with matrix-org/matrix-js-sdk#5186. Reproduce the 412 after a non-empty payload and verify that the secure-link handshake recovers by re-reading and retrying; done means the QR login completes instead of expiring.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 42/100