Skip to content

perf(bluez): serve Base property reads from the proxy cache#3315

Open
geraldo-netto wants to merge 4 commits into
blueman-project:mainfrom
geraldo-netto:perf/bluez-base-property-cache
Open

perf(bluez): serve Base property reads from the proxy cache#3315
geraldo-netto wants to merge 4 commits into
blueman-project:mainfrom
geraldo-netto:perf/bluez-base-property-cache

Conversation

@geraldo-netto

Copy link
Copy Markdown
Contributor

What

Rework blueman/bluez/Base.py so device/adapter property reads are served from the Gio proxy's local cache instead of a synchronous D-Bus round-trip on every access, and make the cache's freshness explicit.

Four focused commits, each building on the previous:

  1. refactor: extract InstanceRegistry — object-identity caching lived inline in BaseMeta and, via hasattr, leaked across subclasses. Moved to a named per-class registry (cls.__dict__); instances deregister on destroy(). Pure structural change.
  2. perf: cache-first get()get() issued a synchronous Properties.Get on the GLib main loop for every read. The proxy already caches properties and keeps them current via PropertiesChanged, so read get_cached_property first and fall back to a synchronous Get only on a cache miss.
  3. feat: explicit freshnessget(name, fresh=True) forces a refresh; a failed refresh still serves the cached value but flags it stale; PropertiesChanged or a later successful refresh clears the flag. is_stale(name) lets callers that need live state check.
  4. obs: log cached fallback — emit logging.debug (interface, property, error) when a cached value is served after a bus error, so stale-serves are diagnosable.

Why

device["Prop"] is read repeatedly by the manager UI; each read was a blocking system-bus round-trip on the GLib main loop.

Proof

test/benchmarks/bench_bluez_base_get.py models each call_sync as one D-Bus round-trip. 35000 repeated reads of warm properties:

round-trips modeled time
before (always sync) 35000 ~7.0s
after (cache-first) 0 ~0s

Correctness / disconnect handling

Reads now return the proxy's signal-tracked cached value instead of forcing a fetch each call. This does not break disconnect handling (manual or link loss):

  • A disconnect arrives as PropertiesChanged{Connected=false}. Gio.DBusProxy (flags NONE) updates its cached property first, then emits g-properties-changed. blueman's _properties_changed fires on that same signal and drives the row update. The cache update and the UI update are the same event, in order — any read in response sees the fresh value.
  • Object removed (unpair / adapter gone): the cache serves last-known values. The old code did a synchronous Get that errored on the dead object and fell back to the cache anyway, so behaviour is equivalent — just without the error log. blueman destroys the Device on InterfacesRemoved regardless.
  • Read-after-write (issue an action, then re-read in the same main-loop turn before the change signal) is the only case where a cached read could lag. No current call site does this — connect/disconnect go through async _call and the UI reacts to the signal. get(name, fresh=True) is the escape hatch if such a path is ever added.

Audited every synchronous device["Connected"] read (ManagerDeviceList setup/timer/update, NetworkService.available, DisconnectItems, PulseAudioProfile, ShowConnected): all are setup-time, signal-driven, or menu-build reads of last-known state — none depend on a forced live fetch.

Tests

test/bluez/test_base.py (28 cases, ~99% coverage of Base.py) with a FakeProxy harness: registry/identity, cache hit vs miss, PropertiesChanged refresh, an explicit disconnect-via-PropertiesChanged regression (asserts Connected flips to False with zero sync calls), freshness/stale transitions, the cached-fallback log, async _call reply/error/unhandled paths, plus adversarial fuzz over get() names and PropertiesChanged payloads. ruff + flake8 clean.

geraldo-netto and others added 4 commits June 19, 2026 12:40
Object-identity caching lived inline in BaseMeta and, via hasattr, leaked
across subclasses. Move it into a named InstanceRegistry held per concrete
class (cls.__dict__), and deregister an instance on destroy(). Pure structural
change; behaviour preserved.

Adds test/bluez/test_base.py with a FakeProxy harness reused by later commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base.get issued a synchronous Properties.Get on every read, on the GLib main
loop. The Gio proxy already caches properties and keeps them current from
PropertiesChanged, so read get_cached_property first and fall back to a
synchronous Get only on a cache miss.

Benchmark test/benchmarks/bench_bluez_base_get.py models each call_sync as one
D-Bus round-trip: 35000 repeated reads drop from 35000 to 0 when the cache is
warm. Tests cover cached hit, miss->sync fallback, PropertiesChanged refresh,
and the __fallback default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get(name, fresh=True) forces a synchronous refresh; on a failed refresh the
last cached value is still served but the property is flagged stale;
PropertiesChanged or a later successful refresh clears the flag. is_stale(name)
lets call sites that need live state check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A failed refresh silently fell back to the cached property. Emit a
logging.debug naming the interface/property and the error so the stale-serve
is diagnosable. Rounds out test_base.py to ~99% coverage of Base.py
(set/get_properties/__contains__/_call paths), an explicit disconnect-via-
PropertiesChanged regression, plus adversarial fuzz over get() names and
PropertiesChanged payloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant