linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

doctor: use NameHasOwner for xdg-desktop-portal presence check

check_portal() previously introspected the portal object path directly,
which dbus-next refuses to parse on healthy GNOME sessions where the
portal exposes hyphenated property names such as power-saver-enabled.
The bare `except Exception` masked the real InvalidMemberNameError and
caused `solstone-linux doctor` to report `fail`, blocking
`make install-service` on working systems.

Fix: ask the D-Bus daemon (org.freedesktop.DBus) whether
org.freedesktop.portal.Desktop is a currently-owned well-known name via
NameHasOwner. The daemon's introspection XML has no hyphenated members,
so the parser bug does not apply. The check is bounded by a 2s
asyncio.wait_for and distinguishes three failure modes (not registered,
bus unreachable, timed out) with dedicated detail strings.

Adds five direct unit tests for check_portal, including a regression
pin that fails against the old implementation when a fake bus would
raise InvalidMemberNameError on portal-path introspection.

Follow-up (not in scope): activity.py and screencast.py also call
bus.introspect() on service object paths and would hit the same
parser bug on any service that exposes a hyphenated member; today
they hide it behind `except Exception: pass` and degrade silently.

Co-Authored-By: OpenAI Codex <codex@openai.com>

+184 -12
+49 -12
src/solstone_linux/doctor.py
··· 20 20 [("name", str), ("severity", str), ("detail", str)], 21 21 ) 22 22 23 + _PORTAL_CHECK_TIMEOUT_SEC: float = 2.0 24 + 23 25 24 26 def check_python_version() -> CheckResult: 25 27 version = tuple(sys.version_info[:2]) ··· 100 102 async def check_portal() -> CheckResult: 101 103 from dbus_next.aio import MessageBus 102 104 from dbus_next.constants import BusType 105 + from dbus_next.errors import AuthError, DBusError, InvalidAddressError 103 106 104 - bus = None 107 + async def _body() -> CheckResult: 108 + bus = None 109 + try: 110 + try: 111 + bus = await MessageBus(bus_type=BusType.SESSION).connect() 112 + except (OSError, AuthError, InvalidAddressError, DBusError) as e: 113 + return CheckResult( 114 + "xdg-desktop-portal", 115 + "fail", 116 + f"session bus unreachable: {e}", 117 + ) 118 + try: 119 + intro = await bus.introspect( 120 + "org.freedesktop.DBus", "/org/freedesktop/DBus" 121 + ) 122 + obj = bus.get_proxy_object( 123 + "org.freedesktop.DBus", "/org/freedesktop/DBus", intro 124 + ) 125 + iface = obj.get_interface("org.freedesktop.DBus") 126 + owned = await iface.call_name_has_owner( 127 + "org.freedesktop.portal.Desktop" 128 + ) 129 + except (DBusError, OSError) as e: 130 + return CheckResult( 131 + "xdg-desktop-portal", 132 + "fail", 133 + f"session bus unreachable: {e}", 134 + ) 135 + if owned: 136 + return CheckResult( 137 + "xdg-desktop-portal", 138 + "ok", 139 + "org.freedesktop.portal.Desktop registered on session bus", 140 + ) 141 + return CheckResult( 142 + "xdg-desktop-portal", 143 + "fail", 144 + "org.freedesktop.portal.Desktop not registered on session bus", 145 + ) 146 + finally: 147 + if bus is not None: 148 + bus.disconnect() 149 + 105 150 try: 106 - bus = await MessageBus(bus_type=BusType.SESSION).connect() 107 - await bus.introspect( 108 - "org.freedesktop.portal.Desktop", 109 - "/org/freedesktop/portal/desktop", 110 - ) 111 - except Exception: 151 + return await asyncio.wait_for(_body(), timeout=_PORTAL_CHECK_TIMEOUT_SEC) 152 + except asyncio.TimeoutError: 112 153 return CheckResult( 113 154 "xdg-desktop-portal", 114 155 "fail", 115 - "xdg-desktop-portal not responding on session bus", 156 + f"timed out after {_PORTAL_CHECK_TIMEOUT_SEC:g}s", 116 157 ) 117 - finally: 118 - if bus is not None: 119 - bus.disconnect() 120 - return CheckResult("xdg-desktop-portal", "ok", "portal responding") 121 158 122 159 123 160 def check_user_systemd() -> CheckResult:
+135
tests/test_doctor.py
··· 3 3 4 4 import sys 5 5 6 + import pytest 7 + 6 8 from solstone_linux import doctor 7 9 8 10 ··· 147 149 148 150 assert result.severity == "ok" 149 151 assert "not applicable" in result.detail 152 + 153 + 154 + class _FakeIface: 155 + def __init__(self, owned=True, raises=None): 156 + self._owned = owned 157 + self._raises = raises 158 + 159 + async def call_name_has_owner(self, name): 160 + if self._raises is not None: 161 + raise self._raises 162 + return self._owned 163 + 164 + 165 + class _FakeProxy: 166 + def __init__(self, iface): 167 + self._iface = iface 168 + 169 + def get_interface(self, name): 170 + return self._iface 171 + 172 + 173 + class _FakeBus: 174 + def __init__( 175 + self, 176 + bus_type=None, 177 + iface=None, 178 + connect_exc=None, 179 + introspect_exc_for_portal=None, 180 + introspect_hang=False, 181 + ): 182 + self._iface = iface or _FakeIface() 183 + self._connect_exc = connect_exc 184 + self._introspect_exc_for_portal = introspect_exc_for_portal 185 + self._introspect_hang = introspect_hang 186 + self.introspect_calls = [] 187 + self.disconnected = False 188 + 189 + async def connect(self): 190 + if self._connect_exc is not None: 191 + raise self._connect_exc 192 + return self 193 + 194 + async def introspect(self, service, path): 195 + self.introspect_calls.append((service, path)) 196 + if service == "org.freedesktop.portal.Desktop": 197 + if self._introspect_exc_for_portal is not None: 198 + raise self._introspect_exc_for_portal 199 + if self._introspect_hang: 200 + import asyncio as _a 201 + 202 + await _a.Event().wait() 203 + return object() 204 + 205 + def get_proxy_object(self, service, path, intro): 206 + return _FakeProxy(self._iface) 207 + 208 + def disconnect(self): 209 + self.disconnected = True 210 + 211 + 212 + @pytest.mark.asyncio 213 + async def test_check_portal_registered_returns_ok(monkeypatch): 214 + fake_instance = _FakeBus(iface=_FakeIface(owned=True)) 215 + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type=None: fake_instance) 216 + 217 + result = await doctor.check_portal() 218 + 219 + assert result.severity == "ok" 220 + assert "registered" in result.detail 221 + assert fake_instance.disconnected is True 222 + 223 + 224 + @pytest.mark.asyncio 225 + async def test_check_portal_not_registered_returns_fail(monkeypatch): 226 + fake_instance = _FakeBus(iface=_FakeIface(owned=False)) 227 + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type=None: fake_instance) 228 + 229 + result = await doctor.check_portal() 230 + 231 + assert result.severity == "fail" 232 + assert "not registered" in result.detail 233 + assert "unreachable" not in result.detail 234 + assert "timed out" not in result.detail 235 + 236 + 237 + @pytest.mark.asyncio 238 + async def test_check_portal_bus_unreachable_returns_fail(monkeypatch): 239 + fake_instance = _FakeBus(connect_exc=OSError("no bus")) 240 + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type=None: fake_instance) 241 + 242 + result = await doctor.check_portal() 243 + 244 + assert result.severity == "fail" 245 + assert "unreachable" in result.detail 246 + assert "no bus" in result.detail 247 + 248 + 249 + @pytest.mark.asyncio 250 + async def test_check_portal_timeout_returns_fail(monkeypatch): 251 + fake_instance = _FakeBus(introspect_hang=True) 252 + monkeypatch.setattr(doctor, "_PORTAL_CHECK_TIMEOUT_SEC", 0.05) 253 + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type=None: fake_instance) 254 + 255 + result = await doctor.check_portal() 256 + 257 + assert result.severity == "fail" 258 + assert "timed out" in result.detail 259 + assert fake_instance.disconnected is True 260 + 261 + 262 + @pytest.mark.asyncio 263 + async def test_check_portal_tolerates_hyphenated_portal_properties(monkeypatch): 264 + from dbus_next.errors import InvalidMemberNameError 265 + 266 + fake_instance = _FakeBus( 267 + iface=_FakeIface(owned=True), 268 + introspect_exc_for_portal=InvalidMemberNameError( 269 + "invalid member name: power-saver-enabled" 270 + ), 271 + ) 272 + monkeypatch.setattr("dbus_next.aio.MessageBus", lambda bus_type=None: fake_instance) 273 + 274 + result = await doctor.check_portal() 275 + 276 + assert result.severity == "ok" 277 + assert "registered" in result.detail 278 + assert all( 279 + service != "org.freedesktop.portal.Desktop" 280 + for service, _path in fake_instance.introspect_calls 281 + ), ( 282 + "check_portal() should not introspect org.freedesktop.portal.Desktop; " 283 + f"calls were {fake_instance.introspect_calls!r}" 284 + )