diff --git a/blueman/bluez/Base.py b/blueman/bluez/Base.py index dde308734..6177f7f89 100644 --- a/blueman/bluez/Base.py +++ b/blueman/bluez/Base.py @@ -10,20 +10,47 @@ DBUS_TIMEOUT = 10 * 1_000 +class InstanceRegistry: + """Caches one :class:`Base` instance per D-Bus object path. + + Object-identity caching used to live inline in :class:`BaseMeta`. Pulling + it into a small, named collaborator keeps the metaclass focused on + construction and makes the cache independently testable and replaceable. + """ + + def __init__(self) -> None: + self._instances: dict[str, "Base"] = {} + + def get(self, path: str) -> "Base | None": + return self._instances.get(path) + + def add(self, path: str, instance: "Base") -> None: + self._instances[path] = instance + + def remove(self, path: str) -> None: + self._instances.pop(path, None) + + def clear(self) -> None: + self._instances.clear() + + class BaseMeta(GObjectMeta): def __call__(cls, *args: object, **kwargs: str) -> "Base": - if not hasattr(cls, "__instances__"): - cls.__instances__: dict[str, "Base"] = {} + registry: InstanceRegistry | None = cls.__dict__.get("_registry") + if registry is None: + registry = InstanceRegistry() + cls._registry = registry path = kwargs.get('obj_path') if path is None: path = getattr(cls, "_obj_path") - if path in cls.__instances__: - return cls.__instances__[path] + existing = registry.get(path) + if existing is not None: + return existing instance: "Base" = super().__call__(*args, **kwargs) - cls.__instances__[path] = instance + registry.add(path, instance) return instance @@ -36,7 +63,7 @@ class Base(GObject.Object, metaclass=BaseMeta): __gsignals__: GSignals = { 'property-changed': (GObject.SignalFlags.NO_HOOKS, None, (str, object, str)) } - __instances__: dict[str, "Base"] + _registry: InstanceRegistry _interface_name: str @@ -62,6 +89,10 @@ def __init__(self, *, obj_path: ObjectPath): self.__variant_map = {str: 's', int: 'u', bool: 'b'} + # Properties whose last forced refresh failed and are being served from + # cache. PropertiesChanged clears the flag once a live value arrives. + self.__stale: set[str] = set() + def _properties_changed(self, _proxy: Gio.DBusProxy, changed_properties: GLib.Variant, invalidated_properties: list[str]) -> None: changed = changed_properties.unpack() @@ -69,8 +100,13 @@ def _properties_changed(self, _proxy: Gio.DBusProxy, changed_properties: GLib.Va logging.debug(f"{object_path} {changed} {invalidated_properties} {self}") for key in list(changed) + invalidated_properties: + self.__stale.discard(key) self.emit("property-changed", key, changed.get(key, None), object_path) + def is_stale(self, name: str) -> bool: + """True if ``name`` is currently served from cache after a failed refresh.""" + return name in self.__stale + def _call( self, method: str, @@ -97,7 +133,17 @@ def callback( self.__proxy.call(method, param, Gio.DBusCallFlags.NONE, DBUS_TIMEOUT, None, callback, reply_handler, error_handler) - def get(self, name: str) -> Any: + def get(self, name: str, fresh: bool = False) -> Any: + # Prefer the proxy's local property cache, which Gio keeps current from + # PropertiesChanged signals, over a synchronous Properties.Get round-trip + # on every read. Bluez devices expose the same properties repeatedly to + # the UI; serving them from cache avoids a blocking D-Bus call each time. + # Callers that need a guaranteed-live value pass fresh=True. + if not fresh: + cached = self.__proxy.get_cached_property(name) + if cached is not None: + return cached.unpack() + try: prop = self.__proxy.call_sync( 'org.freedesktop.DBus.Properties.Get', @@ -105,15 +151,20 @@ def get(self, name: str) -> Any: Gio.DBusCallFlags.NONE, DBUS_TIMEOUT, None) + self.__stale.discard(name) return prop.unpack()[0] except GLib.Error as e: - property = self.__proxy.get_cached_property(name) - if property is not None: - return property.unpack() - elif name in self.__fallback: + # The refresh failed: fall back to the last cached value if we have + # one, but record that it is stale so callers can tell. + cached = self.__proxy.get_cached_property(name) + if cached is not None: + logging.debug(f"{self._interface_name}.{name}: serving cached value after " + f"refresh error: {e.message}") + self.__stale.add(name) + return cached.unpack() + if name in self.__fallback: return self.__fallback[name] - else: - raise parse_dbus_error(e) + raise parse_dbus_error(e) def set(self, name: str, value: str | int | bool) -> None: v = GLib.Variant(self.__variant_map[type(value)], value) @@ -145,6 +196,9 @@ def get_properties(self) -> dict[str, Any]: return props def destroy(self) -> None: + registry = type(self).__dict__.get("_registry") + if registry is not None: + registry.remove(self.get_object_path()) if self.__proxy: del self.__proxy diff --git a/test/benchmarks/bench_bluez_base_get.py b/test/benchmarks/bench_bluez_base_get.py new file mode 100644 index 000000000..b0d4cdf39 --- /dev/null +++ b/test/benchmarks/bench_bluez_base_get.py @@ -0,0 +1,113 @@ +"""Benchmark for the bluez Base property-read path (perf-4). + +Before perf-4, ``Base.get`` issued one synchronous ``Properties.Get`` D-Bus +round-trip on every read. After perf-4 it serves from the proxy's local cache +(kept current by PropertiesChanged), so repeated reads of the same property +cost zero round-trips. + +Each ``call_sync`` models one synchronous D-Bus round-trip (the dominant cost +on this blocking path). Output is one JSON line comparing modeled round-trips +for the old "always sync" behaviour against the current cached-first ``get``. +""" +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib # noqa: E402 + +_constants = types.ModuleType("blueman.Constants") +for _name in ("BIN_DIR", "BLUETOOTHD_PATH", "ICON_PATH", "PIXMAP_PATH", "UI_PATH"): + setattr(_constants, _name, Path("/tmp")) +sys.modules.setdefault("blueman.Constants", _constants) + +from blueman.bluemantyping import ObjectPath # noqa: E402 +from blueman.bluez.Base import Base # noqa: E402 + +# Cost of one synchronous D-Bus round-trip on the system bus; conservative. +DBUS_RTT = 0.0002 # 200 microseconds + +# A device row reads this many properties repeatedly while the manager is open. +PROPS = ["Connected", "Paired", "Trusted", "Blocked", "Icon", "Class", "Address"] + + +class CountingVariant: + def __init__(self, value: Any) -> None: + self._value = value + + def unpack(self) -> Any: + return self._value + + +class CountingProxy: + """Fake proxy counting synchronous round-trips, with a warm cache.""" + + def __init__(self, props: dict[str, Any]) -> None: + self._props = props + self._cached = dict(props) # warm, as Gio loads + signal-updates it + self.sync_calls = 0 + + def connect(self, *_args: Any) -> int: + return 1 + + def get_object_path(self) -> str: + return "/org/bluez/hci0/dev_AA" + + def get_interface_name(self) -> str: + return "org.bluez.Device1" + + def get_cached_property(self, name: str) -> CountingVariant | None: + if name in self._cached: + return CountingVariant(self._cached[name]) + return None + + def call_sync(self, _method: str, params: Any, *_a: Any) -> CountingVariant: + self.sync_calls += 1 + name = params.unpack()[1] + return CountingVariant((self._props[name],)) + + +class FakeBase(Base): + _interface_name = "org.bluez.Device1" + + +def run(reads_per_prop: int) -> dict[str, Any]: + props = {p: (True if p in ("Connected", "Paired", "Trusted", "Blocked") else "x") for p in PROPS} + proxy = CountingProxy(props) + with patch("blueman.bluez.Base.Gio.DBusProxy.new_for_bus_sync", return_value=proxy): + obj = FakeBase(obj_path=ObjectPath("/org/bluez/hci0/dev_AA")) + + total_reads = reads_per_prop * len(PROPS) + for _ in range(reads_per_prop): + for p in PROPS: + obj.get(p) + + new_roundtrips = proxy.sync_calls # cached-first: expected 0 + old_roundtrips = total_reads # always-sync: one per read + return { + "reads": total_reads, + "old_roundtrips": old_roundtrips, + "new_roundtrips": new_roundtrips, + "dbus_rtt": DBUS_RTT, + "old_modeled_seconds": old_roundtrips * DBUS_RTT, + "new_modeled_seconds": new_roundtrips * DBUS_RTT, + "roundtrips_saved_pct": (old_roundtrips - new_roundtrips) / old_roundtrips * 100, + } + + +def _silence_glib_unused() -> None: + # GLib imported for parity with the module under test / future use. + assert GLib is not None + + +if __name__ == "__main__": + _silence_glib_unused() + reads = int(sys.argv[1]) if len(sys.argv) > 1 else 5000 + print(json.dumps(run(reads))) diff --git a/test/bluez/Makefile.am b/test/bluez/Makefile.am index 3ab3f329f..11004d630 100644 --- a/test/bluez/Makefile.am +++ b/test/bluez/Makefile.am @@ -3,5 +3,6 @@ SUBDIRS = \ EXTRA_DIST = \ __init__.py \ + test_base.py \ test_imports.py \ test_manager.py diff --git a/test/bluez/test_base.py b/test/bluez/test_base.py new file mode 100644 index 000000000..9c604eb6a --- /dev/null +++ b/test/bluez/test_base.py @@ -0,0 +1,397 @@ +from typing import Any, Optional +from unittest import TestCase +from unittest.mock import patch + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib # noqa: E402 + +from blueman.bluemantyping import ObjectPath # noqa: E402 +from blueman.bluez.Base import Base, InstanceRegistry # noqa: E402 +from blueman.bluez.errors import BluezDBusException # noqa: E402 + + +class FakeVariant: + """Minimal stand-in for GLib.Variant: only ``unpack()`` is used by Base.""" + + def __init__(self, value: Any) -> None: + self._value = value + + def unpack(self) -> Any: + return self._value + + +class FakeAsyncResult: + def __init__(self, value: tuple, error: "Optional[GLib.Error]") -> None: + self.value = value + self.error = error + + +class FakeProxy: + """Fake Gio.DBusProxy counting sync round-trips and serving a cache.""" + + def __init__(self, props: Optional[dict[str, Any]] = None, + object_path: str = "/org/test/dev0") -> None: + self.props: dict[str, Any] = dict(props or {}) + self.cached: dict[str, Any] = dict(self.props) + self.object_path = object_path + self.sync_get_calls = 0 + self.sync_getall_calls = 0 + self.async_calls: list[tuple[str, Any]] = [] + self.get_should_raise: Optional[GLib.Error] = None + self._pchanged_cb: Any = None + self._last_async: tuple[Any, Any, Any] = (None, None, None) + + def connect(self, signal: str, cb: Any) -> int: + if signal == "g-properties-changed": + self._pchanged_cb = cb + return 1 + + def get_object_path(self) -> str: + return self.object_path + + def get_interface_name(self) -> str: + return "org.test.Interface" + + def get_cached_property(self, name: str) -> Optional[FakeVariant]: + if name in self.cached: + return FakeVariant(self.cached[name]) + return None + + def call_sync(self, method: str, params: Any, *_args: Any) -> FakeVariant: + _iface, *rest = params.unpack() + if method == "org.freedesktop.DBus.Properties.Get": + self.sync_get_calls += 1 + if self.get_should_raise is not None: + raise self.get_should_raise + if rest[0] not in self.props: + raise GLib.Error.new_literal( + GLib.quark_from_string("g-dbus-error-quark"), + "GDBus.Error:org.bluez.Error.DoesNotExist:No such property", 0) + return FakeVariant((self.props[rest[0]],)) + if method == "org.freedesktop.DBus.Properties.GetAll": + self.sync_getall_calls += 1 + return FakeVariant((dict(self.props),)) + raise AssertionError(f"unexpected call_sync {method}") + + def call(self, method: str, params: Any, _flags: Any = None, _timeout: Any = None, + _cancellable: Any = None, callback: Any = None, reply: Any = None, + error: Any = None) -> None: + self.async_calls.append((method, params)) + self._last_async = (callback, reply, error) + + def call_finish(self, result: "FakeAsyncResult") -> FakeVariant: + if result.error is not None: + raise result.error + return FakeVariant(result.value) + + # Test helper: fire the most recent async .call() callback. + def fire_last(self, value: tuple = (), raise_error: Optional[GLib.Error] = None) -> None: + callback, reply, error = self._last_async + if callback is None: + return + callback(self, FakeAsyncResult(value, raise_error), reply, error) + + # Test helper: simulate a D-Bus PropertiesChanged signal. + def emit_properties_changed(self, changed: dict[str, Any], + invalidated: Optional[list[str]] = None) -> None: + self.cached.update(changed) + self.props.update(changed) + assert self._pchanged_cb is not None + self._pchanged_cb(self, FakeVariant(changed), invalidated or []) + + +class FakeBase(Base): + _interface_name = "org.test.Interface" + + +class OtherFakeBase(Base): + _interface_name = "org.test.OtherInterface" + + +def make(cls: type, proxy: FakeProxy, path: str = "/org/test/dev0") -> Any: + with patch("blueman.bluez.Base.Gio.DBusProxy.new_for_bus_sync", return_value=proxy): + return cls(obj_path=ObjectPath(path)) + + +def _clear_registries() -> None: + for cls in (FakeBase, OtherFakeBase): + registry = cls.__dict__.get("_registry") + if registry is not None: + registry.clear() + + +class TestInstanceRegistry(TestCase): + def test_add_get_remove_clear(self) -> None: + reg = InstanceRegistry() + sentinel = object() + self.assertIsNone(reg.get("/p")) + reg.add("/p", sentinel) # type: ignore[arg-type] + self.assertIs(reg.get("/p"), sentinel) + reg.remove("/p") + self.assertIsNone(reg.get("/p")) + reg.add("/p", sentinel) # type: ignore[arg-type] + reg.clear() + self.assertIsNone(reg.get("/p")) + + def test_remove_missing_is_noop(self) -> None: + reg = InstanceRegistry() + reg.remove("/absent") # must not raise + + +class TestBaseInstanceCaching(TestCase): + def setUp(self) -> None: + _clear_registries() + self.addCleanup(_clear_registries) + + def test_same_path_returns_same_instance(self) -> None: + obj = make(FakeBase, FakeProxy(), "/org/test/dev0") + again = make(FakeBase, FakeProxy(), "/org/test/dev0") + self.assertIs(again, obj) + + def test_different_paths_distinct(self) -> None: + a = make(FakeBase, FakeProxy(object_path="/org/test/a"), "/org/test/a") + b = make(FakeBase, FakeProxy(object_path="/org/test/b"), "/org/test/b") + self.assertIsNot(a, b) + + def test_registry_is_per_class(self) -> None: + make(FakeBase, FakeProxy(), "/org/test/dev0") + make(OtherFakeBase, FakeProxy(object_path="/org/test/dev0"), "/org/test/dev0") + self.assertIsNot(FakeBase.__dict__.get("_registry"), + OtherFakeBase.__dict__.get("_registry")) + + def test_destroy_removes_from_registry(self) -> None: + obj = make(FakeBase, FakeProxy(object_path="/org/test/dev0"), "/org/test/dev0") + obj.destroy() + replacement = make(FakeBase, FakeProxy(object_path="/org/test/dev0"), "/org/test/dev0") + self.assertIsNot(replacement, obj) + + +class TestBasePropertyCache(TestCase): + def setUp(self) -> None: + _clear_registries() + self.addCleanup(_clear_registries) + + def test_cached_read_avoids_sync_call(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + self.assertIs(obj.get("Connected"), True) + self.assertEqual(proxy.sync_get_calls, 0) + + def test_repeated_cached_reads_stay_zero_round_trips(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + for _ in range(50): + obj.get("Connected") + self.assertEqual(proxy.sync_get_calls, 0) + + def test_cache_miss_falls_back_to_sync_get(self) -> None: + proxy = FakeProxy() + proxy.props["Address"] = "AA:BB:CC:DD:EE:FF" # present live, absent from cache + obj = make(FakeBase, proxy) + self.assertEqual(obj.get("Address"), "AA:BB:CC:DD:EE:FF") + self.assertEqual(proxy.sync_get_calls, 1) + + def test_properties_changed_refreshes_cache(self) -> None: + proxy = FakeProxy({"Connected": False}) + obj = make(FakeBase, proxy) + self.assertIs(obj.get("Connected"), False) + proxy.emit_properties_changed({"Connected": True}) + self.assertIs(obj.get("Connected"), True) + self.assertEqual(proxy.sync_get_calls, 0) + + def test_disconnect_signal_reflected_without_sync(self) -> None: + # A device disconnect (manual or link loss) arrives as a + # PropertiesChanged(Connected=false); the cached read must observe it + # immediately and without a synchronous round-trip. + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + self.assertIs(obj.get("Connected"), True) + proxy.emit_properties_changed({"Connected": False}) + self.assertIs(obj.get("Connected"), False) + self.assertEqual(proxy.sync_get_calls, 0) + + def test_fallback_used_when_missing_and_sync_fails(self) -> None: + proxy = FakeProxy() + proxy.get_should_raise = GLib.Error.new_literal(GLib.quark_from_string("x"), "boom", 0) + obj = make(FakeBase, proxy) + self.assertEqual(obj.get("Icon"), "blueman") # __fallback default + + def test_getitem_delegates_to_get(self) -> None: + proxy = FakeProxy({"Paired": True}) + obj = make(FakeBase, proxy) + self.assertIs(obj["Paired"], True) + + +class TestBaseCacheFreshness(TestCase): + def setUp(self) -> None: + _clear_registries() + self.addCleanup(_clear_registries) + + def _err(self) -> GLib.Error: + return GLib.Error.new_literal(GLib.quark_from_string("x"), "bus down", 0) + + def test_fresh_forces_sync_even_when_cached(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + obj.get("Connected", fresh=True) + self.assertEqual(proxy.sync_get_calls, 1) + + def test_not_stale_by_default(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + obj.get("Connected") + self.assertFalse(obj.is_stale("Connected")) + + def test_failed_fresh_serves_cache_and_marks_stale(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + proxy.get_should_raise = self._err() + self.assertIs(obj.get("Connected", fresh=True), True) # served from cache + self.assertTrue(obj.is_stale("Connected")) + + def test_properties_changed_clears_stale(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + proxy.get_should_raise = self._err() + obj.get("Connected", fresh=True) + self.assertTrue(obj.is_stale("Connected")) + proxy.emit_properties_changed({"Connected": False}) + self.assertFalse(obj.is_stale("Connected")) + + def test_successful_sync_clears_stale(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + proxy.get_should_raise = self._err() + obj.get("Connected", fresh=True) + self.assertTrue(obj.is_stale("Connected")) + proxy.get_should_raise = None + obj.get("Connected", fresh=True) + self.assertFalse(obj.is_stale("Connected")) + + def test_logs_debug_when_serving_cache_after_error(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + proxy.get_should_raise = self._err() + with self.assertLogs(level="DEBUG") as cm: + obj.get("Connected", fresh=True) + self.assertTrue(any("serving cached value after" in m for m in cm.output)) + + +class TestBaseMisc(TestCase): + def setUp(self) -> None: + _clear_registries() + self.addCleanup(_clear_registries) + + def test_set_issues_async_call(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + obj.set("Trusted", True) + self.assertEqual(proxy.async_calls[-1][0], "org.freedesktop.DBus.Properties.Set") + + def test_setitem_delegates_to_set(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + obj["Alias"] = "phone" + self.assertEqual(proxy.async_calls[-1][0], "org.freedesktop.DBus.Properties.Set") + + def test_get_properties_includes_fallback(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + props = obj.get_properties() + self.assertIs(props["Connected"], True) + self.assertEqual(props["Icon"], "blueman") # fallback filled in + + def test_contains_uses_get_properties(self) -> None: + proxy = FakeProxy({"Connected": True}) + obj = make(FakeBase, proxy) + self.assertIn("Connected", obj) + self.assertNotIn("Nonexistent", obj) + + def test_get_object_path(self) -> None: + proxy = FakeProxy(object_path="/org/test/dev0") + obj = make(FakeBase, proxy) + self.assertEqual(obj.get_object_path(), "/org/test/dev0") + + def test_call_dispatches_async(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + obj._call("Connect") + self.assertEqual(proxy.async_calls[-1][0], "Connect") + + def test_call_reply_handler_receives_value(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + got: list[Any] = [] + obj._call("Connect", reply_handler=lambda *a: got.append(a)) + proxy.fire_last(value=(1, 2)) + self.assertEqual(got, [(1, 2)]) + + def test_call_error_handler_receives_bluez_error(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + errs: list[Any] = [] + obj._call("Connect", error_handler=errs.append) + proxy.fire_last(raise_error=GLib.Error.new_literal( + GLib.quark_from_string("x"), "GDBus.Error:org.bluez.Error.Failed:nope", 0)) + self.assertEqual(len(errs), 1) + self.assertIsInstance(errs[0], BluezDBusException) + + def test_call_unhandled_error_is_logged(self) -> None: + proxy = FakeProxy() + obj = make(FakeBase, proxy) + obj._call("Connect") + with self.assertLogs(level="ERROR") as cm: + proxy.fire_last(raise_error=GLib.Error.new_literal( + GLib.quark_from_string("x"), "GDBus.Error:org.bluez.Error.Failed:nope", 0)) + self.assertTrue(any("Unhandled error" in m for m in cm.output)) + + def test_get_properties_keeps_present_fallback_key(self) -> None: + proxy = FakeProxy({"Icon": "phone", "Connected": True}) + obj = make(FakeBase, proxy) + props = obj.get_properties() + self.assertEqual(props["Icon"], "phone") # not overwritten by fallback + + def test_properties_changed_emits_signal(self) -> None: + proxy = FakeProxy({"Connected": False}) + obj = make(FakeBase, proxy) + seen: list[tuple[str, Any]] = [] + obj.connect_signal("property-changed", lambda _o, k, v, _p: seen.append((k, v))) + proxy.emit_properties_changed({"Connected": True}) + self.assertIn(("Connected", True), seen) + + +class TestBaseFuzz(TestCase): + """Adversarial inputs to the read/refresh paths must never raise unexpectedly.""" + + def setUp(self) -> None: + _clear_registries() + self.addCleanup(_clear_registries) + + def test_get_arbitrary_names_only_raises_bluez_error(self) -> None: + known = {"Connected": True, "Class": 0, "Icon": "blueman"} + proxy = FakeProxy(dict(known)) + obj = make(FakeBase, proxy) + names = [ + "", " ", "\t", "\n", "Connected", "UUIDs", "x" * 4096, "na/me", + "Conn:ected", "0", "../etc", "Ünïcode", "drop;table", "%s%n", + ] + for i, name in enumerate(names): + with self.subTest(name=name): + try: + obj.get(name, fresh=bool(i % 2)) + except BluezDBusException: + pass # acceptable: unknown property over the bus + + def test_properties_changed_arbitrary_payloads_never_raise(self) -> None: + proxy = FakeProxy({"Connected": True}) + make(FakeBase, proxy) # connects proxy to the handler under test + payloads = [ + {}, {"A": 1}, {"": None}, {"x" * 1000: "y" * 1000}, + {"Ünïcode": "✓", "n\x00ul": 1}, + ] + invalidateds = [[], ["A"], ["", "B"], ["x" * 1000]] + for i, changed in enumerate(payloads): + with self.subTest(i=i): + proxy.emit_properties_changed(changed, invalidateds[i % len(invalidateds)])