From 69b86a95a4bdcb9ae464fc862b384117a0556384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Thu, 26 Feb 2026 16:57:09 +0100 Subject: [PATCH 1/2] Fix invalidating cache on device-removed event With broken cache invalidation, plugging new device in the same port as an old one returned stale info from cache, which then got rejected when looking for 'device' instance for an event handler. The end result was that device-added handler got VirtualDevice() instance, instead of DeviceInfo() and that made qui-domains widget unhappy: Failed to handle event: sys-usb, device-added:usb, {'device': sys-usb+4-1:0bda:8179:00E04C0001:uffffff} Traceback (most recent call last): File "/usr/lib/python3.13/site-packages/qubesadmin/events/__init__.py", line 278, in handle kwargs['device'] = plugged ^^^^^^^^^^^^^^^^^^^^^ File "/usr/lib/python3.13/site-packages/qui/devices/device_widget.py", line 262, in device_added dev = backend.Device(device, self) File "/usr/lib/python3.13/site-packages/qui/devices/backend.py", line 156, in __init__ for interface in dev.interfaces: ^^^^^^^^^^^^^^ AttributeError: 'VirtualDevice' object has no attribute 'interfaces' There were several issues: - using 'elif' made the code never called, as 'device-removed' event is handled earlier too - "port" is not the same as "port id" - the former is backend-name:port-id - the actual line missed "del" Fixes: c95a89c "devices: invalidate device cache on device-removed event" Fixes QubesOS/qubes-issues#10674 --- qubesadmin/events/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qubesadmin/events/__init__.py b/qubesadmin/events/__init__.py index af76d446..bd2826f7 100644 --- a/qubesadmin/events/__init__.py +++ b/qubesadmin/events/__init__.py @@ -257,10 +257,11 @@ def handle(self, subject, event, **kwargs): ): devclass = event.split(":")[1] subject.devices[devclass]._attachment_cache = None - elif event.split(":")[0] in ("device-removed",): + if event.split(":")[0] in ("device-removed",): devclass = event.split(":")[1] + port_id = kwargs.get("port", ":").split(":")[1] try: - subject.devices[devclass]._dev_cache[kwargs["port"]] + del subject.devices[devclass]._dev_cache[port_id] except KeyError: pass From ad1779ea4a471c5cd1e311c1e52481bfd397e552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Fri, 27 Feb 2026 04:26:03 +0100 Subject: [PATCH 2/2] tests: check devices cache invalidation on device-removed event QubesOS/qubes-issues#10764 --- qubesadmin/tests/devices.py | 54 ++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/qubesadmin/tests/devices.py b/qubesadmin/tests/devices.py index 6b923856..da143a9a 100644 --- a/qubesadmin/tests/devices.py +++ b/qubesadmin/tests/devices.py @@ -20,6 +20,7 @@ # pylint: disable=missing-docstring +from unittest import mock import qubesadmin.tests import qubesadmin.device_protocol @@ -27,7 +28,7 @@ from qubesadmin.device_protocol import ( DeviceAssignment, DeviceInfo, UnknownDevice, AssignmentMode) - +from qubesadmin.events import EventsDispatcher serialized_test_device = ( b"0\0dev1 port_id='dev1' devclass='test' vendor='itl' product='test-device'" @@ -416,3 +417,54 @@ def test_085_allow_device_multiple(self): qubesadmin.device_protocol.DeviceInterface("m******"), ) self.assertAllCalled() + + def test_100_cache_invalidate(self): + # this also enables caching + dispatcher = EventsDispatcher(self.app) + handler = mock.Mock() + dispatcher.add_handler("device-added:test", handler) + self.app.expected_calls[ + ("test-vm", "admin.vm.device.test.Available", None, None) + ] = ( + serialized_test_device + + b"device_id='1234:5678:0123456789:?*******'\n" + ) + vm = self.app.domains.get("test-vm") + # this also populates cache + dev = vm.devices["test"]["dev1"] + self.assertIsInstance(dev, DeviceInfo) + self.assertNotIsInstance(dev, UnknownDevice) + self.assertEqual(dev.device_id, "1234:5678:0123456789:?*******") + dispatcher.handle( + "test-vm", + "device-added:test", + device="test-vm:dev1:1234:5678:0123456789:?*******", + ) + handler.assert_called_once_with(vm, "device-added:test", device=dev) + handler.reset_mock() + dispatcher.handle("test-vm", "device-removed:test", port="test-vm:dev1") + self.app.expected_calls[ + ("test-vm", "admin.vm.device.test.Available", None, None) + ] = ( + serialized_test_device + + b"device_id='8765:4321:0123456789:?*******'\n" + ) + dispatcher.handle( + "test-vm", + "device-added:test", + device="test-vm:dev1:8765:4321:0123456789:?*******", + ) + handler.assert_called_once_with( + vm, "device-added:test", device=mock.ANY + ) + self.assertIsInstance( + handler.mock_calls[0].kwargs["device"], DeviceInfo + ) + self.assertNotIsInstance( + handler.mock_calls[0].kwargs["device"], UnknownDevice + ) + self.assertEqual( + handler.mock_calls[0].kwargs["device"].device_id, + "8765:4321:0123456789:?*******", + ) + self.assertAllCalled()