feat(bus): wake on NameOwnerChanged instead of polling out the cooldown (#669) - #683
feat(bus): wake on NameOwnerChanged instead of polling out the cooldown (#669)#683vibechoom wants to merge 1 commit into
Conversation
…wn (#669) #668 stopped a contested D-Bus name retrying `RequestName` at 4 Hz forever by feeding the `Exists`/`InQueue` path into the consecutive-loss tally, so `permanent_after` trips, latches, and drops to a 5-minute cooldown. That fix stays. What it cost was recovery: when the squatter exits we noticed on the next cooldown wake, so stopping mako left notifications dead for up to five minutes with nothing saying why. Poll-with-cooldown is the wrong shape for "wait until a name is released" — D-Bus already pushes that event. The give-up now `select!`s on the cooldown timer AND the `NameOwnerChanged` subscription this task already holds, and re-requests the instant the signal reports the name unowned. - `wait_for_release_or_cooldown` is the select. The timer stays as the backstop: a missed signal must degrade to "recovers in five minutes", never to "stranded forever". It is generic over the stream, so the select and the filtering are covered by hermetic tests with hand-built messages. - No startup race: `run_ownership` already subscribes strictly before the first `RequestName` (#429 cites this ordering as the reference the property path failed to follow), so a release landing between the attempt and the wait is already buffered in the stream. Nothing was reordered; the ordering is now load-bearing and commented as such. - Filtering is per-name at the broker (the `arg0` match rule), with an in-process re-check, so one of the six owned names releasing cannot wake the other five. - The recovery logs at `warn!` with `RECOVERED`, matching networkd's startup refresh and wifi's backend probe (#609/#613/#634): the give-up is a `warn!`, so a quieter recovery would be invisible to exactly the person who saw the problem. - The tally is deliberately untouched — `record_loss` still takes `Option<&str>` and an unattributable loss still continues the tally (#668); turning that back into a sentinel reintroduced the original bug once already. `run_inner_loop`'s owned-then-lost arm moves into `on_name_held`, symmetric with `on_name_taken`, so both routes into a give-up share `give_up_and_wait` (and the function stays under the 100-line pedantic cap). Closes #669 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adversarial review — the parts CI cannot checkVerdict: sound. No correctness defect found. I attacked the Line numbers are against Finding 1 — the failure mode the backstop is justified by cannot happen in zbus 5.14What. The per-match-rule channel is created at Why it matters, given no code change is needed. The backstop is still correctly justified by the other reasons stated (broker restart,
Refutation attempted. (i) Maybe zbus enables overflow elsewhere — grepped the whole crate source, Suggested fix. Correct the two comments to name the real mechanisms (broker restart / lost Severity: maintenance hazard (low). Doc-only; the code is right. Finding 2 — the attempt/log ceiling stops being self-imposed and becomes peer-imposedWhat. #653's complaint and #668's fix were about a ceiling we controlled: 12 attempts and 12 warn lines an hour, whatever the peer does. After this PR, Failure sequence. A contender that flaps the name — a notification daemon crash-looping, or any process doing Refutation attempted. (i) systemd's Related, same site: the Suggested fix. Cheap version: keep the Severity: maintenance hazard (low). Finding 3 — one redundant latency assertion in the new hermetic testsWhat. Refutation attempted. It would catch an implementation that returns Suggested fix. Delete the Severity: minor / style. Coverage — what I attacked and why it survived
What I did not verifyThe live scenario itself. Nothing in |
The trade this un-makes
#668 (#653) fixed a real flood: a contested bus name used to retry
RequestNameat ~4 Hz forever — 14,400 calls an hour, silently. Feeding theExists/InQueuepath intoconsecutive_losses_tomadepermanent_aftertrip, latch, and drop to a cooldown: 12 attempts an hour, each logged. That fix stays, untouched.What it cost was recovery. When the squatter exits we no longer noticed within 250 ms — we noticed on the next cooldown wake, up to ~5 minutes later. If mako or dunst holds
org.freedesktop.Notificationswhen trollshell starts and you then stop it, notifications stayed dead for up to five minutes with nothing indicating why.Poll-with-cooldown is the wrong shape for "wait until a name is released". D-Bus already pushes that event.
The select
give_up_and_waitno longer sleeps out the cooldown. It callswait_for_release_or_cooldown, whichselect!s on:NameOwnerChangedstream this task already holds — waking on an emptynew_owner, which is the broker saying the name is now unowned,and re-attempts immediately on the signal. Near-instant recovery and the 12/hour ceiling, because the fast path is event-driven rather than polled: the attempt rate is now bounded by real ownership transitions of this one name (the broker emits exactly one per change), not by how short a sleep we dared to use.
The cooldown stays as the backstop, deliberately. A signal can be missed — the shared connection's message queue can overflow during a long wait, a match rule can fail to re-register across a broker restart — and a missed signal must degrade to "recovers in five minutes", never to "stranded forever".
a_silent_subscription_falls_through_to_the_cooldown_backstoppins that, and asserts the full cooldown was served so an early return can't masquerade as the backstop.The two wakes take deliberately different next moves, split into the pure
next_attempt_afterso the reasoning has somewhere to live:ReleasedRemoveMatch/AddMatchchurnrun_ownershipdocuments as racy, for nothing.CooldownThe startup race (#429's shape)
Nothing was reordered, because
own.rswas already correct here — and #429 says so explicitly: it citesown.rs("subscribes NameOwnerChanged before RequestName") as the reference the property path failed to follow.run_ownershipbuilds theMessageStreaminsidewith_conn, strictly beforerun_inner_loopissues anyRequestName.That ordering was merely tidy before; it is load-bearing now. A holder that exits between the
RequestNamethat told us the name was taken and the start of the wait has already had itsNameOwnerChangedbuffered into the stream, so the wait returns on it immediately instead of falling through to the five-minute path — which would be exactly this bug, just rarer and much harder to reproduce. That is spelled out in a comment at the subscription site, including the constraint that nothing between there andrun_inner_loopmay re-subscribe.Per-name filtering
Six names go through this path (
notifications, tray,DisplayConfig, screensaver, bluetooth,Control), each with its own instance of this task. Filtering is at the broker:build_name_owner_changed_rulealready sets anarg0match on the name, so another name's release never reaches this process at all.is_release_ofre-checks the name in-process as a second line of defence — one string compare, and it means a mis-built rule degrades into a missed wake (backstop catches it) rather than a wrong one.only_a_release_of_our_own_name_wakes_usfeeds another name's release — the exact shape of our wake, on the wrong name — and asserts it is stepped over.An ownership transfer (non-empty
new_owner) is likewise not a wake: the name isn't free, so re-requesting would only earn anotherExists.What the fast recovery logs
warn!withRECOVERED, matchingnetworkd's startup refresh andwifi's backend probe (#609/#613/#634) rather than inventing a shape. The level is the point:log_give_upis awarn!, so a recovery logged quieter would be invisible to exactly the person who saw the problem — they'd read "notifications are inert" and never get a line saying it stopped being true. It fires once per release actually observed, not on a timer.log_give_up's own message also now says it is watching for the release, not just re-checking periodically.Not regressed from #668
record_lossstill takesOption<&str>, and an unattributable loss still continues the tally rather than starting a new one. That shape exists because an<unknown>sentinel previously reintroduced the original bug through tally oscillation; all six of fix(bus): log and back off a contested bus name instead of retrying silently at 4 Hz (#653) #668's hermetic tests are untouched and still pass.permanent_afterbudget against the same peer (fix(bus): log and back off a contested bus name instead of retrying silently at 4 Hz (#653) #668's deliberate non-reset). Staying latched is much cheaper now that the latch is event-driven — it costs one attempt per release, not one per 250 ms. The latch and this change reinforce each other.Tests
Hermetic (7 new, in
own.rs'smod tests, nodbus-daemon) —wait_for_release_or_cooldownis generic over the stream, so the select and the filtering are driven with hand-builtzbus::Messages:a_release_of_the_wanted_name_ends_the_wait_at_onceonly_a_release_of_our_own_name_wakes_us— the per-name filteran_ownership_transfer_is_not_a_releasea_silent_subscription_falls_through_to_the_cooldown_backstop— asserts the full cooldown elapsedan_ended_stream_still_serves_out_the_cooldown— a dead stream returnsNoneforever; an early return here would spin the retry loop at CPU speedan_undecodable_body_is_not_a_releasethe_wake_decides_whether_the_subscription_is_reused— the select-arm decisiontokio'stest-util(virtual time) isn't enabled in this crate andCargo.tomlwas deliberately not touched, so the timing tests use a 60 ms real cooldown where they mean to reach the backstop and a 30 s one where they must not — the streams are in-memory and resolve in microseconds, so there is ~3 orders of magnitude of margin either way, and a regression fails in 30 s rather than hanging.Gated (
crates/hytte-bus/tests/own_release.rs, a new file —tests/common/mod.rsdeliberately untouched, #678 is editing it):a_released_name_is_reacquired_without_waiting_out_the_cooldown. A squatter takes the name withoutAllowReplacementso every attempt returnsExistsand the primitive latchesPermanentlyTaken; the cooldown is set to 10 minutes against a 10-second recovery budget, so the cooldown wake cannot be what makes it pass — a regression times out instead of passing slowly. Deterministic by construction (the squatter owns the name before the primitive starts and holds it across the latch), and it runs in ~0.26 s.Gates
All run locally in the devShell with
RUSTC_WRAPPERunset:cargo clippy --workspace --all-targets --features system-tests -- -D warnings— cleancargo test --workspace— cleanxvfb-run -a cargo test --workspace --features system-tests— clean (54 suites)nix fmt— cleanrun_inner_loop's owned-then-lost arm moved intoon_name_held, symmetric withon_name_taken, so both routes into a give-up sharegive_up_and_waitand can't drift — andrun_inner_loopstays under the 100-line pedantic cap it had crossed.Live-verify
Nothing in CI runs a competing notification daemon, so the scenario the issue is actually about needs a human on a live Niri session:
systemctl --user start mako(ordunst) before trollshell, then start the shell.systemctl --user stop mako.D-Bus name RECOVEREDwarn within milliseconds, not minutes, andnotify-send hiworking immediately after. Before this PR that took up to five minutes and logged nothing when it happened.Worth also checking the backstop hasn't been broken in the process: with mako left running, the give-up warn should still repeat about every 5 minutes and no more often.
Closes #669
🤖 Generated with Claude Code