From aa6e6164706ea562d5180d8b5ea475658ac63675 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 30 Jun 2026 14:24:42 +0100 Subject: [PATCH] Don't delete the dehydrated device when MAS syncs the device list (#19892) `_synapse/mas/sync_devices` checks the device list against the set of devices MAS knows about, but the dehydrated device (MSC3814) is invisible to MAS, so it gets automatically deleted on each sync, which prevents dehydrated devices from working. This change excludes the dehydrated device from the check. There is similar special case code in the admin devices API (which gives it a special flag) and MAS's own legacy sync path (which filters it out). This code was initially written by @ara4n and Claude, but both he and I have read it and think it makes sense. I am far from a Synapse expert, so feel free to tell me it's all wrong and point me in the right direction. A system-level test for this bug is being written here: https://github.com/element-hq/element-web/issues/34034 but if you think we should have one somewhere else, please let me know. Closes https://github.com/element-hq/synapse/issues/19889 ### 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: * [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: Matthew --- changelog.d/19892.bugfix | 1 + synapse/rest/synapse/mas/devices.py | 10 ++++++ tests/rest/synapse/mas/test_devices.py | 42 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 changelog.d/19892.bugfix diff --git a/changelog.d/19892.bugfix b/changelog.d/19892.bugfix new file mode 100644 index 0000000000..9439433274 --- /dev/null +++ b/changelog.d/19892.bugfix @@ -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. diff --git a/synapse/rest/synapse/mas/devices.py b/synapse/rest/synapse/mas/devices.py index 9d94a67675..b5d19d3cec 100644 --- a/synapse/rest/synapse/mas/devices.py +++ b/synapse/rest/synapse/mas/devices.py @@ -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 diff --git a/tests/rest/synapse/mas/test_devices.py b/tests/rest/synapse/mas/test_devices.py index 6b7596f1c6..e9035f4c0b 100644 --- a/tests/rest/synapse/mas/test_devices.py +++ b/tests/rest/synapse/mas/test_devices.py @@ -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(