Don't delete the dehydrated device when MAS syncs the device list

The modern MAS device-sync endpoint (_synapse/mas/sync_devices) reconciled
the user's device list against the set of devices MAS knows about, but the
dehydrated device (MSC3814) has no MAS session and so was never in that set.
It was therefore treated as an extra device and deleted on every sync — in
particular when a user logged out their last device — which broke offline
key delivery via the dehydrated device.

Exclude the dehydrated device from the reconciliation, mirroring the admin
devices API (which flags it) and MAS's own legacy sync path (which filters
it out).
This commit is contained in:
Matthew
2026-06-26 12:32:56 +01:00
committed by Matthew Hodgson
parent 1f0c2bc3e4
commit bb890ed723
3 changed files with 53 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Fix a bug where a user's dehydrated device (MSC3814) was deleted when their device list was synced from MAS (e.g. on logging out their last device), breaking offline key delivery.
+10
View File
@@ -196,6 +196,16 @@ class MasSyncDevicesResource(MasBaseResource):
current_devices_list = set(current_devices.keys())
target_device_list = set(body.devices)
# Exclude the dehydrated device (MSC3814): it has no MAS session, so MAS
# never lists it in the target set and the reconciliation below would
# otherwise treat it as extra and delete it. This mirrors the admin
# devices API and MAS's own legacy device-sync path, which both skip it.
dehydrated_device = await self.device_handler.get_dehydrated_device(
user_id=str(user_id)
)
if dehydrated_device is not None:
current_devices_list.discard(dehydrated_device[0])
to_add = target_device_list - current_devices_list
to_delete = current_devices_list - target_device_list
+42
View File
@@ -546,6 +546,48 @@ class MasSyncDevicesResource(BaseTestCase):
devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id)))
self.assertEqual(set(devices.keys()), {"DEVICE1"})
def test_sync_devices_preserves_dehydrated_device(self) -> None:
# Store a dehydrated device (MSC3814). It has no MAS session, so it is
# never part of the target device list, but it must survive a sync.
device_handler = self.hs.get_device_handler()
dehydrated_device_id = self.get_success(
device_handler.store_dehydrated_device(
user_id=str(self.alice_user_id),
device_id=None,
device_data={"device_data": {"foo": "bar"}},
initial_device_display_name="dehydrated device",
keys_for_device={},
)
)
# Sync down to a single real device. The dehydrated device is not in the
# target list, but must not be deleted.
channel = self.make_request(
"POST",
"/_synapse/mas/sync_devices",
shorthand=False,
access_token=self.SHARED_SECRET,
content={
"localpart": "alice",
"devices": ["DEVICE1"],
},
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {})
# The real devices are reconciled, but the dehydrated device survives.
store = self.hs.get_datastores().main
devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id)))
self.assertEqual(set(devices.keys()), {"DEVICE1", dehydrated_device_id})
# And it is still registered as the dehydrated device.
dehydrated = self.get_success(
device_handler.get_dehydrated_device(str(self.alice_user_id))
)
assert dehydrated is not None
self.assertEqual(dehydrated[0], dehydrated_device_id)
def test_sync_devices_add_and_delete(self) -> None:
# Sync with a mix of additions and deletions
channel = self.make_request(