Skip to content

fix: answer concurrent remote key queries after a device resync in the federation shape - #304

Open
nikzen wants to merge 2 commits into
masterfrom
fix/device-resync-cached-shape
Open

nikzen wants to merge 2 commits into
masterfrom
fix/device-resync-cached-shape

Conversation

@nikzen

@nikzen nikzen commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Fixed a bug where a remote key query could fail for a whole server when another query had just fetched the same user's devices.

What goes wrong

_user_device_resync_returning_failed returns early when the user's device cache became valid while it waited in the resync linearizer. That early return passes on the raw cache mapping ({device_id: device}). The caller, E2eKeysHandler._query_devices_for_destination, expects the shape of a federation user_devices response and reads result["devices"]:

query A ─ resync(user) ── fetches over federation, fills cache ── ok
query B ─ resync(user) ── waits in linearizer ── cache valid → returns {device_id: device}
          _query_devices_for_destination: result["devices"] → KeyError('devices')
          → failures[destination] = {"status": 503, "message": "'devices'"}

So when two clients (or two devices of one user) query the keys of a remote user whose devices aren't cached yet at the same moment, the second one gets a 503 failure for the whole destination. Clients then back off that server and encrypt without the user's devices, so the user can't read the message.

The bug is also present in element-hq/synapse develop.

Fix

 if await self.store.get_users_whose_devices_are_cached([user_id]):
     cached = await self.store.get_cached_devices_for_user(user_id)
-    return cached, False
+    result = {"user_id": user_id, "devices": list(cached.values())}
+    + master_key / self_signing_key from the cross-signing key store, if present
+    return result, False

The cache stores the device objects exactly as the federation response carried them, so list(cached.values()) is the same devices list. The cross-signing keys are added too; without them the second query would answer without the user's master key.

The only caller that reads the result is _query_devices_for_destination. The other callers (federation_event, devicemessage, the background retry) only check it for None.

Tests

  • tests/handlers/test_e2e_keys.py::E2eKeysHandlerTestCase::test_query_devices_remote_concurrent_resync: two concurrent query_devices calls for the same uncached remote user, with the federation response held back until both are waiting.
    • Before: {'other': {'status': 503, 'message': "'devices'"}} != {}
    • After: both answers have no failures, the device and the master key.
    • Leaving out the cross-signing part makes it fail on the missing master key.
  • tests.handlers.test_e2e_keys, tests.handlers.test_device, tests.storage.test_devices: 56 tests passed. ruff check, ruff format and mypy are clean on the changed files.
  • End-to-end on a two-homeserver setup where a bot with two replicas queries a newly joined remote user: before, the first message regularly went out without the user's device; after, it no longer did.

Merge danger

Door: two-way. No schema or stored-data change; a revert restores the old return value.

Blast radius: small. Only the early-return path of the resync changes, and its only reader expected exactly this shape.

🤖 Generated with Claude Code

`_user_device_resync_returning_failed` returns early when another resync
filled the cache while this one waited in the linearizer. It returned the
raw cache mapping (device ID -> device), but `_query_devices_for_destination`
reads `result["devices"]` and the cross-signing keys. Two concurrent key
queries for a remote user whose devices weren't cached yet made the second
one raise `KeyError('devices')`, which ended up as a 503 failure for the
whole destination. Clients then back off that server and encrypt without
the user's devices.

The early return now builds the same shape as the federation response,
including the cached cross-signing keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nikzen
nikzen requested a review from a team as a code owner November 5, 2026 14:25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.29%. Comparing base (e2df54d) to head (77be239).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #304   +/-   ##
=======================================
  Coverage   81.29%   81.29%           
=======================================
  Files         511      511           
  Lines       74391    74396    +5     
  Branches    11315    11317    +2     
=======================================
+ Hits        60473    60479    +6     
+ Misses      10595    10594    -1     
  Partials     3323     3323           
Files with missing lines Coverage Δ
synapse/handlers/device.py 76.89% <100.00%> (+0.66%) ⬆️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e2df54d...77be239. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant