fix(bus): log and back off a contested bus name instead of retrying silently at 4 Hz (#653) - #668
Merged
Merged
Conversation
…ilently at 4 Hz (#653) `own.rs`'s `RequestNameReply::Exists | InQueue` arm — the one that fires when another daemon already holds the name and refused replacement — set `Lost`, slept 250 ms, set `Acquiring` and looped, on the same connection, forever. It never touched `consecutive_losses_to`, so `permanent_after` could not trip from it, and it emitted no tracing line at all. If mako or dunst owns `org.freedesktop.Notifications`, trollshell produced no toast, no log line and no UI tell while making ~14,400 `RequestName` calls an hour for the process lifetime. `Lost` and `PermanentlyTaken` were silent on the displacement path too; only `AccessDenied` logged anything. Both paths now feed one `record_loss` decision, so `permanent_after` trips from either. At the default of 3 a contested name warns, waits 250 ms, warns, waits 500 ms, then latches `PermanentlyTaken` and drops to the 5-minute cooldown: 14,400 `RequestName`/hour becomes 12. The tally deliberately stays latched rather than resetting on give-up, so each cooldown wake makes exactly one attempt instead of a fresh burst of `permanent_after` — that is what caps both the retry rate and the log rate at one per cooldown. On the cadence question this repo has an argument about (#634 vs #646): a contested name is a *static* condition, so it must not log at the retry rate, but it must still be findable in a journal opened hours later. One warn per cooldown gives both, and the reasoning is written into `log_give_up` so it does not have to be re-derived. A best-effort `GetNameOwner` on the failure path names the holder in every log line and populates `Lost.prev_owner` / `PermanentlyTaken.current_owner`, which were `None`/unreachable on this path before. That lookup is where the first version of this fix went wrong, and it is worth recording. Keying the tally on a `"<unknown>"` sentinel when `GetNameOwner` failed passed the new gated test but hung the pre-existing `permanently_taken_after_three_losses`: the lookup does not fail rarely, it fails *systematically*, because a peer that refuses replacement often releases the name moments later, so the broker answers `NameHasNoOwner` between the two calls. The tally oscillated `:1.1`→1, `<unknown>`→1 forever and `permanent_after` could never trip — the original bug, reintroduced through a different door. `record_loss` therefore takes `Option<&str>`, and an unattributable loss continues the running tally and leaves its key alone. An all-unknown sequence still escalates, so an unanswering broker cannot pin us in a retry loop either. The below-threshold gap grows with the consecutive count so a name that is only briefly held is not declared permanently taken after half a second. Deliberately one multiplication against the existing constant, not a fourth hand-rolled backoff type (#646). Also corrects a stale comment: `consecutive_losses_to` was documented as resetting on every successful re-acquisition. It never did, and the real behaviour — counting losses to one peer across re-acquisitions — is the useful one, since that is exactly what detects a replacement war. Tests: the accounting is now a pure function with five hermetic tests covering accumulation, alternating holders, the unattributable-loss rule, all-unknown escalation, and the anti-burst latch. A new gated test races a real second owner that takes the name without `AllowReplacement`, so every `RequestName` returns `Exists` — the real shape of the mako collision — and asserts both `PermanentlyTaken` and the squatter's actual unique name. Non-flaky by construction: the squatter takes the name before the primitive starts and never releases it. `tests/own.rs` run 5x back to back, 5/5 green. Step 2 of #653 (consuming `OwnState` in `notifications.rs` / `tray/mod.rs`) is not here: the log-only half would now near-duplicate these lines, and the visible bar tell is UI work in another lane. Hence `Refs`, not `Closes`. Refs #653 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This was referenced Jul 30, 2026
vibechoom
added a commit
that referenced
this pull request
Jul 31, 2026
…urst falsified (#672) Closes #660 Bring docs/live-verify.md current across the ten PRs that merged after the last refresh (#634): #637, #639, #644, #645, #662, #663, #664, #666, #668, plus #642 (coverage bump only, per the issue's own conclusion — its default station-id fix needs no live-verify caveat since write_default_config only touches an absent places.toml). Corrected the #630 entry: it told a verifier to expect modal::close_all tearing down with window.close(); #637 changed that call to destroy() after #630 had already merged, so the entry named a call the code no longer makes (exactly the issue's finding). Also added a new #645 entry to the Network panel section for the networkd startup-refresh retry, since that path used to latch "no link manager has answered yet" forever on a transient first-refresh failure (curable only by a shell restart) and now self-heals — swept the rest of the doc for the same class of falsified expectation (inert-until-restart) and found nothing else affected; the wifi-side #609/#634 entries describe a different code path and are unaffected. New entries added: - #637/#639 — the destroy()-not-close() sweep across the five trollshell overlays and the three remaining hytte-ui sites (bar.rs, popup.rs), including #637's WeakRef/toplevels()-count kanshi hot-plug gesture. - #644/#663 — the RefCell-borrow-across-GTK-call sweep (31 sites total across overlays/modal.rs and the rest of the UI, incl. an 8-panel-wide reactive_list.rs helper and the control-center). Both documented with the same "nothing changes" honest pass condition #630 already used. - #664 — mpris Auto chip fix + bare-string artist widening. Needs two live MPRIS players and a shell restart for the new CSS class, both called out. - #666 — new top-level "Claude bridge" section for the keyless loopback shim, nobody has run this service live yet. Carries the PR's flagged riskiest assumption (hive-claude's SessionNotFound classification) with its journal tell. - #668 — new top-level "D-Bus name ownership" section. Documents that recovery going from ~250ms to up to 5 minutes after a squatter exits is the expected cost of the fix, not a regression to file. Left alone: #642 (no doc change needed beyond the coverage line, matching the issue's own recommendation after checking places.toml's write-once behavior); #637's abort_subscription/prompt.rs exclusions and #644/#663's "deliberately not touched" sites (no live-visible behavior, nothing to verify). Gates: markdown-only diff (git diff --stat: 1 file, docs/live-verify.md). nix fmt run twice (1 file reformatted first pass — a prose reflow — 0 changed second pass). Clippy/tests deliberately skipped: nix/package.nix's crane filter excludes markdown, so the packaged derivation is unaffected and a nix build would burn a full workspace recompile for zero signal (the same crane-filter trap #662 already documented). nix build was not run, per instructions. Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #653
Step 1 only (the
own.rs-confined half). Step 2 — consumingOwnStateinnotifications.rs/tray/mod.rs— is not here; reasoning below.The control flow, as I read it
I re-derived this from the file rather than trusting the citations, since #653
is explicit that it was never reproduced against a live competing daemon. My
reading matches the issue's, and the load-bearing detail checks out against
zbus 5.14's own docs:
run_inner_looprequests the name withAllowReplacement | ReplaceExisting | DoNotQueue.RequestNameReply::Existsisdocumented as "the name already had an owner,
DoNotQueuewas specified, andeither
AllowReplacementwas not specified by the current owner, orReplaceExistingwas not specified by the requesting application" — we alwayspass
ReplaceExisting, soExistsmeans the current owner refused to bereplaced. Asking again cannot succeed while they hold it. (
InQueueisunreachable with
DoNotQueueset, but the arm covers it.)That arm then set
Lost { transient: false, prev_owner: None }, slept 250 ms,set
Acquiring, and looped — on the same connection, so not even a reconnectbroke the cycle. It never touched
consecutive_losses_to, sopermanent_aftercould not trip from it. Net: ~4
RequestNamecalls/second for the wholeprocess lifetime, ~14,400/hour, with zero log output — plus a
Mutablewriteper iteration, so any future subscriber would have been woken at 4 Hz too.
One thing the issue does not mention, which I found while restructuring: the
comment on
consecutive_losses_toclaimed it was "reset to None each time wesuccessfully (re-)acquire the name". It never was — nothing resets it on
acquisition. The actual behaviour (count losses to one peer across
re-acquisitions) is the more useful one, since that is exactly what detects a
replacement war, so I fixed the comment rather than the code.
What is logged, and at what level
All lines carry
name(the contested well-known name) and, where known,holder(the unique name actually holding it — resolved with one best-effortGetNameOwneron the failure path).Exists/InQueue, below thresholdwarn!Exists/InQueue, latchingPermanentlyTakenwarn!warn!PermanentlyTakenwarn!debug!AccessDeniedinfo!The transient loss is
debug!on purpose:connection.rsalready warns aboutthe disconnect itself, and this line is only its consequence — warning twice for
one event is how a reconnect storm becomes unreadable.
GetNameOwneralso meansLost.prev_ownerandPermanentlyTaken.current_ownerare now populated on the contested path instead of
None/never-reached.The part I got wrong first, and how it was caught
Worth spelling out, because #653 is explicit that it was never reproduced and
this is precisely where a plausible-looking fix goes wrong.
My first version keyed the tally on a
Stringholder and substituted a"<unknown>"sentinel whenGetNameOwnerfailed. That looked fine and passedthe new gated test — and broke the pre-existing
permanently_taken_after_three_losses, which hung for its full 15 s budget.Instrumenting
record_lossshowed why:GetNameOwnerdoes not fail rarely on this path — it fails systematically,because a peer that refuses replacement often releases the name moments later,
so the broker answers
NameHasNoOwnerbetween ourRequestNameand ourlookup. With
<unknown>treated as a holder in its own right, every secondobservation reset the count to 1 and
permanent_aftercould never trip: theoriginal 4 Hz bug, faithfully reproduced through a different door.
So
record_lossnow takesholder: Option<&str>, and an unattributable losscontinues the running tally and leaves its key alone rather than starting a
new one.
UNKNOWN_HOLDERsurvives only as the display/state string for the casewhere no holder was ever identified — and an all-unknown sequence still
escalates, so a broker that never answers cannot keep us retrying either. Both
rules are pinned by hermetic tests.
UNKNOWN_HOLDERis exported so a consumermatching on
current_ownercan recognise it.The takeaway for review: the gated test that races two real owners is what
turned "reads correct" into "is correct", and the test that caught it was the
one already in the tree.
Cadence — the part with a live argument attached
permanent_afternow trips from theExists/InQueuepath:record_lossfeeds it, so at the default of 3 the sequence is warn, 250 ms, warn, 500 ms,
latch
PermanentlyTaken+ warn, 5-minute cooldown.The below-threshold gap grows with the consecutive count (
RETRY_AFTER_LOSS * consecutive) so a name that is only briefly held is not declared permanentlytaken after half a second — latching lands at ~0.75 s instead. That is one
multiplication against the existing constant, deliberately not a fourth
hand-rolled backoff type (#646); the give-up cooldown, not this ramp, is what
actually bounds the retry rate.
The tally then deliberately stays latched — it is not reset on give-up —
so each cooldown wake makes exactly one
RequestNameand re-latches, ratherthan resetting to zero and firing a fresh burst of
permanent_afterattemptsevery cycle. Steady state against a permanent squatter:
That is the middle ground between the two positions this repo has already
argued, and the reasoning is written into
log_give_up's doc comment so thenext person does not have to re-derive it:
backoff capped at 8 s — the cap is what made per-attempt logging affordable.
The condition there was intermittent and self-healing, so the log was the
measurement.
each time, indefinitely.
A contested well-known name is a static condition — nothing changes until
the other process exits — so it must not log at the retry rate, but it must
still be findable in a journal opened hours after the shell started. Capping the
line to the cooldown gives both at ~0.003 lines/s. And unlike #634's case, the
condition is also carried continuously by the live
OwnStatesignal, so aconsumer that wants it does not need a log line at all.
Honest cost: if the squatter exits, we now notice in up to 5 minutes rather
than 250 ms. That is the same trade the already-shipped
PermanentlyTakenpathmade (its docs already say "the supervisor still retries every 5 minutes"), so
this makes the two paths consistent rather than introducing a new property. The
better fix is to
select!the cooldown against aNameOwnerChangedsaying thename went free — the subscription is already in hand — but that is new race
surface on a path #653 could not reproduce, and it is not what the issue asked
for. Worth a follow-up.
Step 2: not done
Deliberate, not skipped for time.
contested bus name and the process holding it; a second
error!innotifications.rssaying the same thing one level louder adds a word ofdomain consequence and a second journal line per event.
tell" fix(bus): nothing consumes OwnState — a contested bus name is silent and retries RequestName at 4 Hz forever #653 asks for — is UI work in
trollshell/src/{overlays,widgets},which is outside this lane and is not a small diff.
So this is
Refs #653, notCloses. #653 stays open for the consumer half,which is now strictly easier: the state it needs to render is populated with a
real holder identity instead of
None.Tests
Hermetic (default
cargo test), against the extractedrecord_lossdecision sothe accounting is covered without a broker:
permanent_after;regression test for the bug above;
in a retry loop either;
one per cooldown);
Gated (
system-tests),squatted_name_escalates_to_permanently_taken: a secondconnection to the ephemeral broker takes the name without
AllowReplacement, so everyRequestNamefrom the primitive comes backExists— the real shape of the mako-owns-org.freedesktop.Notificationscollision. It asserts
PermanentlyTakenand thatcurrent_owneris thesquatter's actual unique name. Non-flaky by construction: the squatter takes the
name before the primitive starts and holds it for the entire test, so there is
no window in which the primitive could win instead; there is no sleep-and-hope
and no ordering race between the two connections.
Both pre-existing gated tests (
lost_then_reacquired,permanently_taken_after_three_losses) pass unchanged — and both now exercisethe
Existspath, since their campers take the name withoutAllowReplacementtoo, which is how the accounting bug surfaced. The whole
tests/own.rsfile wasrun 5× back to back looking for timing flake: 5/5 green.
Gates
All run locally in the devShell, clippy with
RUSTC_WRAPPERunset:cargo clippy --workspace --all-targets --features system-tests -- -D warnings— cleancargo test --workspace— all passcargo test --workspace --features system-tests(realdbus-daemon, underxvfb-run) — all passnix fmt— 385 files, 0 changedLive-verify
Nothing in CI runs a competing notification daemon, so the end-to-end behaviour
is unverified on a real session. To check on the deployed shell:
org.kde.StatusNotifierWatcher(against another tray host) and the MutterDisplayConfigshim (against a real mutter) take the same path and shouldbehave identically.