From 5376bde14f9bd51c52a98a3d37d495d4d05777da Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 12 Jul 2026 02:34:50 -0700 Subject: [PATCH] receiver: close paired devices before dropping the handle Receiver.close() nulled its own handle first and only then closed its paired devices. A paired device's feature_request() sends over the receiver's handle, so the device.cleanups callbacks that run inside Device.close() - release RGB SW control, restore onboard-profile mode - were issued with handle=None, failed, and were silently swallowed. On every clean Solaar quit a receiver-paired keyboard was left in host mode; on the G915 TKL that means dead F-keys until a power cycle (#3266). Wired devices were unaffected: Device.close() already runs cleanups before clearing its handle, per its own comment. Close the devices first, then drop the handle, mirroring Device.close(). Also log cleanup write failures in rgb_power.cleanup at debug level instead of swallowing them silently, so a torn-down transport can't hide; debug because failures are routine when the device is unplugged. The regression test fails against the previous ordering. --- lib/logitech_receiver/receiver.py | 3 ++- lib/logitech_receiver/rgb_power.py | 4 ++-- tests/logitech_receiver/test_receiver.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/logitech_receiver/receiver.py b/lib/logitech_receiver/receiver.py index e72a69c4..15f9a9cc 100644 --- a/lib/logitech_receiver/receiver.py +++ b/lib/logitech_receiver/receiver.py @@ -189,11 +189,12 @@ class Receiver: def close(self): if logger.isEnabledFor(logging.INFO): logger.info("%s: closing - handle %s %s", self, type(self.handle), self.handle) - handle, self.handle = self.handle, None + # Close paired devices first: their cleanup writes go over this receiver's handle. for _n, d in self._devices.items(): if d: d.close() self._devices.clear() + handle, self.handle = self.handle, None return handle and self.low_level.close(handle) def __del__(self): diff --git a/lib/logitech_receiver/rgb_power.py b/lib/logitech_receiver/rgb_power.py index 96ae6e2f..ce01075c 100644 --- a/lib/logitech_receiver/rgb_power.py +++ b/lib/logitech_receiver/rgb_power.py @@ -338,8 +338,8 @@ def cleanup(device): device.feature_request(SupportedFeature.PROFILE_MANAGEMENT, 0x60, b"\x03") elif device.features and SupportedFeature.ONBOARD_PROFILES in device.features: device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x10, b"\x01") - except Exception: - pass # Device may already be offline + except Exception as e: + logger.debug("%s: cleanup release writes failed (device offline?): %s", device, e) if getattr(device, "_rgb_has_shutdown_cap", False): try: # SetRgbPowerMode(set=1, mode=0) — firmware off transition. diff --git a/tests/logitech_receiver/test_receiver.py b/tests/logitech_receiver/test_receiver.py index 2e8775ec..7af920eb 100644 --- a/tests/logitech_receiver/test_receiver.py +++ b/tests/logitech_receiver/test_receiver.py @@ -369,3 +369,21 @@ def test_force_unpair_slot_no_handle(): assert r.force_unpair_slot(2) is False r._unpair_device_per_receiver.assert_not_called() + + +def test_close_keeps_handle_until_paired_devices_closed(): + """Paired-device cleanup writes go over the receiver's handle, so + Receiver.close must close its devices before dropping it.""" + device_info = DeviceInfo("11") + mock_low_level = LowLevelInterfaceFake(responses_unifying) + r = receiver.create_receiver(mock_low_level, device_info, lambda x: x) + handle_at_device_close = [] + dev = mock.Mock() + dev.close.side_effect = lambda: handle_at_device_close.append(r.handle) + r._devices[1] = dev + + r.close() + + assert handle_at_device_close == [fake_hidpp.open_path("11")] + assert r.handle is None + assert r._devices == {}