refactor(services): one retry policy for wifi and networkd, with non-tautological shipped-policy tests (#646, #665) - #675
Merged
Conversation
…tautological shipped-policy tests Closes #665. Refs #646 (part 1 of two; the 2s-reconnect-loop half is left). Part 1 (#646): `wifi`'s `RetryPolicy`/`ProbeStep` (#634) and `networkd`'s `RefreshRetry`/`RefreshStep` (#645) were near-identical -- same three fields, same shipped constants (None / 500ms / 8s), and a `backoff()` body that was character-for-character the same `checked_shl`/`saturating_mul`/`min` sequence. Only a pair of hand-written cross-referencing doc comments kept them in sync, and #665 is what happened when that failed. Both now use `crate::retry::{Policy, Step}` -- a new private module in `hytte-services`, next to the existing `mod eds_retry`. The surviving shape is `networkd`'s generic `step<T, E>(&Result<T, E>, attempt)`, whose `Proceed` carries no payload; `wifi`'s `probe_until_conclusive` now reads the verdict off the probe's own `Result` instead of out of the step, so the inconclusive-vs- answered weighing is explicit in the caller. That is strictly stronger against #613: with no payload on `Proceed`, no code path can manufacture a `BackendChoice` out of a `ProbeError` at all. Named `retry::Policy` rather than `RetryPolicy` because `hytte_bus::RetryPolicy` already exists and is a different thing (per-call Never/Once/Backoff) -- it is referenced in `PROBE_RETRY`'s own docs, now as an explicit link. Pure refactor: no log line, delay, or control-flow branch changed. Both shipped constants keep `max_attempts: None`, `initial: 500ms`, `max_backoff: 8s`. Deliberately NOT folded in, per #646: `hytte_bus`'s `Backoff` (stateful cursor, no budget, no give-up) and `wifi/watcher.rs:131`'s `sleep(2s)` (a one-shot debounce before `return`, not a retry loop). Both are named in the new module's docs so nobody re-opens them as oversights. #646's second half -- the three fixed-2s reconnect loops in `networkd::listen`, `mpris` and `bluetooth` -- is left alone. Capping those changes delays and log wording, and the interesting part is a design question this change has no answer for: those loops have no clean success signal, so "when does the attempt counter reset?" needs deciding before a budget means anything there. Part 2 (#665): the shipped-policy tests were tautological on the delay. They asserted `step(&failed(), n) == Retry { after: policy.backoff(n) }` -- the very expression `step` computes internally -- so they pinned that `step` calls `backoff`, not that `backoff` returns a sane number. Verified the falsification on 61967dd: setting `STARTUP_REFRESH_RETRY.initial` to `Duration::ZERO` left the whole suite green (681 passed, 0 failed), while the behaviour became `sleep(Duration::ZERO)` on every retry -- a tight `error!` flood, exactly what that constant's own docs argue is impossible. The same edit to `wifi::PROBE_RETRY` on 61967dd DOES go red, confirming #665's one-sided-drift claim: #634 compared against the literal `max_backoff`, #645 replaced that with a re-computation. `retry.rs` now carries a `SHIPPED` table of every policy the crate ships, swept by two tests, so a third policy cannot be added without these assertions coming along: - the first delay is strictly greater than zero (the floor neither side had); - the ceiling is actually reached, compared against the literal `max_backoff` (matching #634's assertion, which #645 had dropped); - first retry <= 1s, `max_backoff` within [initial, 30s]. Every comparison is against a literal, never a re-computation. The per-site `the_shipped_policy_never_goes_inert` tests keep only the site-specific claim (`max_attempts: None`, argued on #613/#621) and now use `matches!`. Also corrects #665 item 2: `networkd`'s comment claimed the 2s loop only covers a `listen` that had been established and dropped, but `listen` opens with its own initial `refresh`, so it also covers one that was never established. The comment now says so, including that a post-seed recovery surfaces as that loop's `warn!` going quiet rather than as a `startup refresh RECOVERED` line. 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.
Two follow-ups from #645, done in one PR because #646 creates the type that
#665's missing assertions belong on.
#646 — one retry policy instead of two
wifi'sRetryPolicy/ProbeStep(#634) andnetworkd'sRefreshRetry/RefreshStep(#645) were near-identical: the same three fieldswith the same meanings, the same shipped constants (
None/ 500 ms / 8 s), and abackoff()body that was character-for-character the samechecked_shl/saturating_mul/minsequence. What kept them in sync was a pair of doccomments cross-referencing each other by hand — and #665 is what that looks
like when it fails.
Where the shared type lives, and why there
New private module
crates/hytte-services/src/retry.rs, holdingretry::Policyand
retry::Step.hytte-services, nothytte-busorhytte-reactive. Both call sites areservice modules, both retry "a system daemon was not there yet", and nothing
outside the crate needs the type. A private
mod retry;keeps it that way.mod eds_retry. That module isthis crate's precedent for "shared resilience primitives, pure, hermetically
testable". It is deliberately not extended to hold this:
eds_retryisblocking /
std::sync::mpsc-shaped and carries a failure-streak detector,not a
Durationschedule with an attempt budget.retry::Policy, notRetryPolicy.hytte_bus::RetryPolicyalreadyexists and is a different thing (per-
callretry:Never/Once/Backoff) — andPROBE_RETRY's own docs reference it two paragraphs fromwhere they described their own policy. Module-qualifying disambiguates at every
use site; that doc reference is now an explicit
hytte_bus::RetryPolicy::Oncelink that says it is a different, per-call policy.
Not folded in, and said so in the module docs so nobody re-opens them as
oversights:
hytte_bus'sBackoff(hytte-bus/src/connection.rs) — a stateful cursorwith
reset()/next(), no attempt budget, no give-up. chore(services): three hand-rolled exponential backoffs, and three fixed-2s reconnect loops with no ceiling (#645 follow-up) #646 rules it out byname; agreed, folding it in would be over-unification.
wifi/watcher.rs:131'ssleep(2s)— deliberately left alone. It is aone-shot debounce before a
returnon the iwd station-removed path, where thecaller re-establishes the watch. It is not a retry loop, and "fixing" it into
one would be a regression. chore(services): three hand-rolled exponential backoffs, and three fixed-2s reconnect loops with no ceiling (#645 follow-up) #646 names it for exactly this reason; the new
module's docs now name it too, so the next person grepping
from_secs(2)finds the reasoning before the code.
How
wifinow weighs inconclusive-vs-failed in the callerThe surviving shape is
networkd's genericstep<T, E>(&Result<T, E>, attempt) -> Step, whoseProceedcarries nopayload.
wifi's oldProbeStep::Commit(BackendChoice)did, which is the onlyreason the two types could not already be one.
probe_until_conclusivenow reads the verdict off the probe's ownResultinthe
Proceedarm instead of getting it back out of the step, and the weighingthat used to be implicit in
step's signature is an explicit comment at the callsite: any answer is conclusive and commits — including
Ok(BackendChoice::None), "the bus replied and neither daemon is present" —while
ProbeError("I could not ask") is the only retryable outcome.This is strictly stronger against #613 than the old shape. With no payload on
Proceed, there is no longer any code path that could manufacture aBackendChoiceout of aProbeError; the collapse #613 fixed is nowunrepresentable rather than merely tested against. (
ProbeErrorcorrespondinglydrops out of the module's imports — since #646 nothing outside
probe_backend'sown signature names it.)
Proof of no behaviour change
No log line, no delay, and no control-flow branch changed on either side.
Concretely:
max_attempts: None,initial: 500ms,max_backoff: 8s.backoff()moved verbatim — samechecked_shl/saturating_mul/min.step()'s three arms are unchanged apart from the enum path and dropping*choicefrom theOkarm.tracing::{info,warn,error}!call inseed_linksandprobe_until_conclusiveis byte-identical, including theattempt == 1/BackendChoice::Nonegating ofwifi's three RECOVERED wordings.The existing tests are the gate, and they pass. Where they moved or changed:
networkd::backoff_doubles_and_clamps_to_the_ceilingretry::tests— identicalbounded()helper, identical assertionswifi::backoff_doubles_and_clamps_to_the_ceilingnetworkd::a_bounded_policy_gives_up_once_its_budget_is_spent+wifi::err_past_the_bound_gives_upretry::testsunder the first namewifi::err_retries_while_attempts_remainretry::testsasa_failed_attempt_retries_while_the_budget_lastswifi::unbounded_policy_never_gives_upretry::tests::an_unbounded_policy_never_gives_up, with the tautologicalafter: forever.backoff(attempt)replaced bymatches!(.., Retry { .. })networkd::a_refresh_that_landed_proceeds_immediately,networkd::a_failed_startup_refresh_retries_instead_of_ending_the_taskanyhow::Resultwifi::ok_commits_on_the_first_attempt,ok_none_commits_and_does_not_retry,inconclusive_probe_never_commits_to_no_backendCommit(choice)expectations becomeProceed, because which verdict it was is no longerstep's to report — see above for why #613's guard gets stronger, not weakernetworkd::the_shipped_policy_starts_promptly_and_stays_audibleretry::tests::every_shipped_policy_starts_promptly_and_stays_audible, which runs the same three assertions over both shipped constantswifi::shipped_policy_is_unbounded_and_starts_promptlythe_shipped_policy_never_goes_inert(matching itsnetworkdsibling); keepsmax_attempts == None, sheds the delay assertions to the sweepNet: every load-bearing assertion still runs; the ones that ran twice now run
once, on the shared type.
#665 — the shipped-policy tests were tautological on the delay
networkd::the_shipped_policy_never_goes_inertassertedThe expected
afteris the expressionstepcomputes internally, so it pinnedthat
stepcallsbackoff— nothing about the numberbackoffreturns. Thecompanion test bounded the delay only from above.
The falsification, run for real
Before (on
61967dd, the pre-refactor tree): change one field,STARTUP_REFRESH_RETRY.initialfromDuration::from_millis(500)toDuration::ZERO, and the suite stays green.The resulting behaviour is
sleep(Duration::ZERO)on every retry.seed_linkslogs every attempt at
error!, so that is not a fast retry — it is a tightloop flooding the journal as fast as the bus can fail, precisely the flood
STARTUP_REFRESH_RETRY's own docs argue is impossible ("logging every attemptrather than every Nth is safe because the backoff caps at 8s").
I also ran #665's "one-sided drift" claim, and it holds exactly: the same edit
to
wifi::PROBE_RETRY.initialon61967dddoes go red —— because #634 compared against the literal
PROBE_RETRY.max_backoff, so azeroed
initialbreaks the ceiling equality. #645 mirrored the policy andreplaced that literal with a re-computation, which is what dropped the guard.
wifistill had no nonzero floor of its own, though.After (this branch), the same
initial: Duration::ZEROedit fails for eitherconstant:
And the ceiling half is live too — breaking
backoff()'s clamp(
.min(self.max_backoff)→.min(self.max_backoff / 2)) reds both themechanism test and the sweep:
What now catches it
retry.rscarries a table of every retry policy the crate ships:Two tests sweep it, so a third policy cannot ship without these assertions
coming along — the table is the only route to coverage, and its doc comment
says why:
every_shipped_policy_sleeps_a_nonzero_time_and_reaches_its_ceiling—backoff(1) > Duration::ZERO(the nonzero floor, which neither side had),backoff(u32::MAX) == policy.max_backoff, and for unbounded policiesstep(&failed(), u32::MAX) == Retry { after: policy.max_backoff }(the ceilingis reached through
step, matching fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) #634's assertion exactly).every_shipped_policy_starts_promptly_and_stays_audible—backoff(1) <= 1s,max_backoff >= initial,max_backoff <= 30s.Every comparison is against a literal — a field of the policy under test, or
Duration::ZERO— never againstpolicy.backoff(...). The per-sitethe_shipped_policy_never_goes_inerttests keep only the claim that is genuinelysite-specific (
max_attempts: None, argued at length on #613 and #621) and nowuse
matches!(.., Retry { .. })rather than re-deriving a delay, with a doccomment pointing at where the delay is actually pinned.
#665 item 2 — the inaccurate comment
networkd.rs's "the 2s retry below … covers alistenthat had been establishedand dropped" was wrong:
listenopens with its own initialrefresh, so thatloop also covers a
listenthat was never established. Corrected in place,including #665's own qualification that this is milder than #621 (the seed has
already published
LinkSource::Networkd, so a later death leaves the list stale,not indeterminate, and it self-heals) and the observability note that a post-seed
recovery surfaces as that loop's
warn!going quiet rather than as astartup refresh RECOVEREDline — so grepping for that line will not evidence areal self-heal. #645's live-verify section is in a merged PR body and can't be
amended, so the note lives at the code.
What I did not do — #646's second half
The three fixed-2s reconnect loops (
networkd::listen's loop atnetworkd.rs,mpris/mod.rs:181,bluetooth/mod.rs:201) still retry flat every 2 s with noceiling. #646 scopes that as "worth doing only if someone is in the file anyway",
and I left it deliberately:
log wording (the "retrying in 2s" text becomes false the moment a backoff
exists), which is exactly what this PR promised not to touch.
does the attempt counter reset? These loops have no clean success signal —
listenreturningOk(())means "the stream ended", which can happenimmediately. A naive counter would back a healthy-but-flappy daemon off to the
ceiling and keep it there. Something like "reset if the last
listenranlonger than N seconds" is a judgement call worth making on its own, not as a
rider.
retry::Policyis now sitting there ready for it, with a longer ceiling. HenceRefs #646rather thanCloses— everything else in #646 is done.Gates
cargo clippy --workspace --all-targets --features system-tests -- -D warnings(
RUSTC_WRAPPERunset) — cleancargo test --workspace— greennix fmt— cleanNo
Cargo.toml/Cargo.lockchange; no new dependency.Live-verify
Nothing to verify on a live session: every line touched is either pure logic
(unit-tested hermetically above) or a comment. The only runtime-visible surface —
seed_links' andprobe_until_conclusive' log lines and sleeps — is unchangedbyte-for-byte, which is what the "no behaviour change" table above is arguing.
Closes #665
Refs #646
🤖 Generated with Claude Code