Skip to content

fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) - #634

Merged
vibechoom merged 2 commits into
mainfrom
fix/wifi-retry-inconclusive-probe-613
Jul 30, 2026
Merged

fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613)#634
vibechoom merged 2 commits into
mainfrom
fix/wifi-retry-inconclusive-probe-613

Conversation

@vibechoom

@vibechoom vibechoom commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #613
Refs #607

Phase 1 only, per the design settled on the #613 thread. Switching backends after one is already running is not here — that's #633, and it's blocked on a cancellation primitive that doesn't exist.

The bug

wifi/mod.rs's select_backend turned Err(ProbeError) — "I could not ask the bus" — into BackendChoice::None — "the bus answered and neither daemon is present". Service::start called it exactly once, so a single transient D-Bus failure at startup latched "no wireless backend" for the whole process lifetime: no watcher spawned, no in-UI hint anything had failed, and only systemctl --user restart trollshell brought Wi-Fi back.

Does this make a restart unnecessary for what Mara hit in #607? Yes, for that failure mode. Mara's case was a transient failure at startup that a restart cured — i.e. the bus was reachable moments later. That is exactly what the retry now covers: the probe keeps asking until it gets a real answer, and Wi-Fi comes up on its own. What it does not cover is a Wi-Fi daemon that starts after the probe has already reached a conclusive None verdict, or a runtime iwd↔NM switch — that's #633.

What changed

probe_backend itself is untouched (#609 already made it honest); the collapse was one layer up.

The obvious fix — retry inside select_backend — would have been worse than the bug: that function ran rt.block_on(...) on the calling thread during Service::start, i.e. the GTK main thread, so a retry loop there freezes shell startup. Instead the probe and the three-arm branch moved inside a single spawn_supervised closure, the shape networkd::NetworkdService::start (networkd.rs:305-368) already uses: one task owns the lifecycle and decides internally, off the main thread, so start() returns immediately.

Retry only while the probe is inconclusive (Err). Commit immediately on any Ok(_) — including Ok(BackendChoice::None), which is a real answer. Retrying "nobody's there" would be a bug of its own (an endless poll on a host that genuinely has no Wi-Fi daemon).

Two mechanical consequences of start() now returning before the backend is known:

  • WifiHandles.backend is a Mutable<WifiBackend> set once the probe concludes, rather than a field fixed in the struct literal; get_backend() reads its current value. Command dispatch (scan, connect_network, …) sees None until then — indistinguishable from the pre-discovery startup window that already exists today.
  • _ownership / _nm_agent became Arc<OnceLock<…>> slots the probe task parks its handles in, since neither exists when the struct is built.

The per-backend watchers keep their own independent spawn_supervised. The decide-task returns once it has committed, and spawn_supervised doesn't restart a clean completion, so a backend is never started twice.

Incidental second fix, not claimed in the title

Pre-PR, select_backend ran rt.block_on(probe_backend()) on the GTK main thread. That means a slow bus didn't just break Wi-Fi — it froze the entire shell for the duration of the probe: roughly 10s with the socket down (two sequential hytte_bus::calls, each fast-failing then waiting up to 5s in wait_for_reconnect), and up to ~50s against a wedged peer (two full 25s timeouts, which is_transient_zbus_error doesn't classify as transient so there's no retry). Moving the probe off the main thread removes that freeze as a side effect. Worth having in the merge note.

The diagnostic is not silenced

This was the requirement that held the fix, so it's treated as one. #609's error! exists to measure how often this fires, and a retry that healed quietly would trade a visible permanent bug for an invisible intermittent one — worse for exactly Mara's situation, where nothing in the UI hinted anything had failed.

  • Every inconclusive attempt logs at error!, with attempt and retry_in_secs. Deliberately every attempt rather than every Nth, because the backoff caps at 8s — so a permanently dead bus stays loud in the journal instead of degrading into a silent poll.
  • A retry that finally succeeds logs wifi: backend probe RECOVERED at warn! with attempts=N.
  • The recovery wording is gated on the verdict, not just on having retried. A host that genuinely runs no Wi-Fi daemon recovers into None, and telling that user "Wi-Fi is coming up" would be a false line in the very diagnostic this PR exists to keep trustworthy — so that case gets its own wording ("the bus is answering again, and the verdict is that NO Wi-Fi daemon is present").
  • The retry line names the backend probe RECOVERED token to grep for, so an error-level-only filter still points at the resolution.

Retry policy — unbounded

Unbounded retry with capped backoff. Attempt 1 is immediate; each retry waits 0.5s doubled per elapsed attempt, clamped at 8s, settling into one probe every 8s for as long as it takes.

I originally shipped a bounded policy (8 attempts, give up at error!). Review killed it, correctly, and the counter-example was in the file I copied the shape from — networkd.rs:283-289 already decided this exact question the other way:

Picking None here is what latched #607: it leaves the link list permanently empty even once the bus recovers. The NM watcher re-polls every 5s, so it self-heals if NM does turn up; the cost if NM genuinely never appears is a periodic GetDevices failed warn, which beats an inert service.

That transfers wholesale, because both Wi-Fi watchers self-heal the same way — wifi_nm.rs's discovery retries every 5s and watcher.rs has its 'discovery loop. A bounded give-up would leave the user in precisely #607's state (Wi-Fi gone, restart the only cure) whenever the bus takes longer than the bound: the bug this PR closes, reintroduced on a longer fuse. And the justification was circular — "giving up is logged at error! so a wrong bound is visible" argues for a bound from the visibility of the bound being wrong. Unbounded retry logging every attempt gets identical visibility with no latch.

GiveUp and its tests stay in the code, so a bounded policy remains one field away (max_attempts: Some(n)); only the shipped default is unbounded, and shipped_policy_is_unbounded_and_starts_promptly asserts that. The same question is open on #621 for networkd's startup refresh — @annikahannig may answer it either way, and the two subsystems should end up consistent.

I also dropped an earlier "~31.5s of wall clock" figure from the comment: that was the sum of the sleeps, assuming a probe attempt costs zero, which it doesn't in exactly the failure mode being retried (see the per-attempt costs in the incidental-fix note above). The comment now describes the schedule in attempts and backoff and states the real per-attempt range instead of asserting a false total.

Tests + falsification

The retry/commit/give-up decision is a pure function, RetryPolicy::step(&outcome, attempt) -> ProbeStep, so it's testable with no bus. 8 new tests, all in the hermetic bucket (plain #[cfg(test)], not system-tests):

ok_commits_on_the_first_attempt, ok_none_commits_and_does_not_retry, err_retries_while_attempts_remain, err_past_the_bound_gives_up, inconclusive_probe_never_commits_to_no_backend, backoff_doubles_and_clamps_to_the_ceiling, unbounded_policy_never_gives_up, shipped_policy_is_unbounded_and_starts_promptly.

Falsification 1 — the latch. With the fix in place: cargo test -p hytte-services671 passed, 0 failed. Reintroducing the original bug (Err(_) => ProbeStep::Commit(BackendChoice::None)):

test wifi::tests::err_past_the_bound_gives_up ... FAILED
test wifi::tests::err_retries_while_attempts_remain ... FAILED
test wifi::tests::inconclusive_probe_never_commits_to_no_backend ... FAILED
test wifi::tests::unbounded_policy_never_gives_up ... FAILED

test result: FAILED. 4 passed; 4 failed

e.g. err_retries_while_attempts_remain: left: Commit(None), right: Retry { after: 10ms }; inconclusive_probe_never_commits_to_no_backend: "attempt 1: an inconclusive probe was committed as 'no backend'".

Falsification 2 — the unbounded guard. Re-bounding the shipped policy to max_attempts: Some(8):

test wifi::tests::shipped_policy_is_unbounded_and_starts_promptly ... FAILED
  assertion `left == right` failed: the shipped probe policy must never give up; see PROBE_RETRY's docs and #621

test result: FAILED. 7 passed; 1 failed

Both edits restored afterwards and verified byte-identical with diff against saved copies, then re-run clean.

Gates

  • cargo clippy -p hytte-services --all-targets --features system-tests -- -D warnings — clean (--all-targets so the new #[cfg(test)] code is linted; RUSTC_WRAPPER unset so sccache can't skip lint emission).
  • cargo test -p hytte-services — 671 passed, 0 failed.
  • nix fmt — 0 changed on both runs.

Docs this makes stale — not edited here

docs/live-verify.md goes false on merge, in two places. PR #628 owns that file and is green awaiting merge, so nothing here touches it; flagging for whoever sequences the merges:

Live-verify

CI cannot exercise a transient bus failure — nothing in nix flake check can make ListNames fail and then succeed. This needs a manual check on a real session:

  1. Start the shell while the system bus / NetworkManager is still coming up (e.g. systemctl --user restart trollshell immediately after systemctl restart NetworkManager, or start it early in a fresh boot's session).
  2. Confirm the Wi-Fi card in the network panel populates on its own, without a shell restart — the network list appears once the probe recovers.
  3. Confirm the journal shows both halves:
    journalctl --user -u trollshell | grep -E 'backend probe (was INCONCLUSIVE|RECOVERED)'
    Expect at least one INCONCLUSIVE line (with attempt= and retry_in_secs=) followed by one RECOVERED line carrying attempts=.
  4. Sanity check the negative case is unchanged: on a host with neither daemon, wifi: no Wi-Fi backend present — service is inactive should still appear once, promptly, with no retry lines — Ok(None) must commit immediately.
  5. Sanity check the shell no longer freezes on a slow bus: startup should stay responsive while the probe is retrying, since it no longer runs on the GTK main thread.

🤖 Generated with Claude Code

…o backend" (#613)

A transient D-Bus failure at startup used to disable Wi-Fi for the whole
process lifetime: `select_backend` collapsed `Err(ProbeError)` ("I could
not ask") into `BackendChoice::None` ("the bus answered and nobody is
there"), the service spawned no watcher, and only a shell restart brought
Wi-Fi back. That is what Mara hit in #607.

The probe now retries while it is *inconclusive* and commits as soon as it
is conclusive — including `Ok(BackendChoice::None)`, which is a real
answer; retrying "nobody's there" would be a bug of its own.

Retrying could not happen where the old probe ran: `select_backend` did
`rt.block_on(...)` on the GTK main thread during `Service::start`, so a
retry loop there would have frozen shell startup. The probe and the
three-arm branch therefore moved inside one `spawn_supervised` closure —
the shape `networkd::NetworkdService::start` already uses. Consequences:

- `WifiHandles.backend` is a `Mutable<WifiBackend>` (set once the probe
  concludes) instead of a field fixed in the struct literal; `get_backend`
  reads its current value. Commands see no backend until the probe
  concludes, the same window that already exists before first discovery.
- `_ownership` / `_nm_agent` became `Arc<OnceLock<…>>` slots the probe
  task parks its handles in, since `start()` now returns before either
  exists.

The retry does not silence #609's diagnostic: every inconclusive attempt
still logs at `error!`, and a successful retry logs a `RECOVERED` line
naming the attempt count, so the journal shows both that it happened and
that it healed.

Policy: bounded retry (8 attempts, 0.5s doubling to an 8s cap, ~31.5s
total), then give up at `error!`. It is one field to swap to retry-forever
(`max_attempts: None`) — the same question is open for networkd's startup
refresh on #621 and the two must end up consistent.

The retry/commit/give-up decision is a pure function (`RetryPolicy::step`)
covered by hermetic tests; reintroducing the original latch reddens four
of them.

Switching backends after one is running is out of scope (#633) — it needs
a cancellation primitive that does not exist yet.

Closes #613
Refs #607

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
)

Review corrections to the retry work in this branch.

1. The retry policy is now UNBOUNDED (`max_attempts: None`).

   `networkd.rs:283-289` already decided this same question the other way,
   and its reasoning transfers wholesale: picking the inert verdict "leaves
   the link list permanently empty even once the bus recovers … the cost if
   NM genuinely never appears is a periodic warn, which beats an inert
   service". Both Wi-Fi watchers self-heal identically — `wifi_nm.rs`'s
   discovery retries every 5s, `watcher.rs` has its `'discovery` loop — so
   a bounded give-up buys nothing and leaves the user in exactly #607's
   state whenever the bus takes longer than the bound: the bug this branch
   closes, on a longer fuse.

   The old justification was also circular ("giving up is logged at
   `error!` so a wrong bound is visible" argues for a bound from the
   visibility of the bound being wrong). Visibility is preserved without
   the latch: every attempt logs at `error!` — deliberately every attempt,
   not every Nth, because the backoff caps at 8s.

   `GiveUp` and its tests stay in the code so a bounded policy remains one
   field away; only the shipped default changed. `shipped_policy_*` now
   asserts the shipped policy is the unbounded one.

2. Dropped the false "~31.5s of wall clock" figure. That was the sum of the
   sleeps, assuming a probe attempt costs zero — which it does not in the
   failure mode being retried. `probe_backend` makes two sequential
   `hytte_bus::call`s at 25s timeout / `RetryPolicy::Once`, so a down socket
   costs ~10s per attempt and a wedged peer up to ~50s (a timeout is not
   transient per `is_transient_zbus_error`, so there is no retry). The
   comment now describes the schedule in attempts and backoff and states
   the real per-attempt range.

3. `RECOVERED` was logged on `attempt > 1` alone, so a host with genuinely
   no Wi-Fi daemon whose first probe was inconclusive got "RECOVERED …
   Wi-Fi is coming up without a shell restart" immediately followed by "no
   Wi-Fi backend present — service is inactive". The wording is now gated
   on the verdict too. This PR's premise is that the diagnostic stays
   trustworthy; a false line in it undercuts that.

4. `let _ = nm_agent_out.set(export)` silently dropped a rejected
   `ExportHandle`, and that drop is not inert: `ExportHandle::drop` →
   `tracker.dec()` → 0 → a path-keyed `unmount(NM_AGENT_PATH)` that takes
   down the interface the surviving export mounted, while NM still holds
   our registration — so `GetSecrets` fails and passphrase prompts stop,
   with nothing logged above `debug`. Latent (it needs a panic after
   commit), silent, and security-adjacent, so it is now guarded with an
   `error!`. The sibling `let _` for `OwnNameSignal` is genuinely harmless
   (no `Drop`) and now says why.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vibechoom
vibechoom merged commit 411f2f5 into main Jul 30, 2026
1 check passed
vibechoom added a commit that referenced this pull request Jul 30, 2026
…630/#634 (#636)

PR #634 (closes #613) makes two existing entries actively wrong rather than
merely stale, which is worse than a gap: a verifier following the old text
would read a successful self-heal as a regression. Corrects both, per #635:

- The #610 "restart NetworkManager" caveat no longer says the backend probe
  runs once at startup; it retries while inconclusive. The deferred re-probe
  gap now points at #633 (the genuinely-unsolved half — switching backends
  after one is already committed) instead of the closed #613.
- The #609 entry no longer expects an `error!` pointing at
  `systemctl --user restart trollshell` on a transient probe failure. The
  shipped retry policy is unbounded and self-heals into a `RECOVERED` line
  instead of giving up.

Also folds in the three PRs that merged alongside #634 since the doc's last
refresh (#628):

- #629 (closes #614): docs/plugin-env.md publishing to the Pages options-doc
  site, new "Documentation site (GitHub Pages)" section (deploy itself is
  outside `nix flake check` by design — flagged as a manual check).
- #630 (closes #627): modal::close_all's borrow-timing fix, added to Shell
  chrome as a regression-only check — explicitly states that no observable
  change is the pass condition.
- #634 itself: new Wi-Fi entry covering the INCONCLUSIVE/RECOVERED journal
  pair and the incidental startup-freeze fix.
- #628: docs-only refresh of this file, noted in the closing section.

Coverage line bumped to #458 through #634. Documentation-only change.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
vibechoom added a commit that referenced this pull request Jul 30, 2026
…oing inert (#621) (#645)

The `LinkBackend::Networkd` arm seeded the link list with one `refresh` and
returned if it failed — before the retry loop, so `loop { listen(...) }` was
unreachable from that path. One unlucky refresh at startup ended the task for
the whole process lifetime: `source` stayed `LinkSource::Unknown`, the panel
showed "no link manager has answered yet" forever, and restarting the shell was
the only cure.

The comment justified that with "networkd isn't running on this host". It
cannot be: this arm is reached only after `probe_link_backend` established that
networkd IS the backend. The genuinely-absent case is `LinkBackend::None`,
handled separately and correctly publishing `LinkSource::Unavailable`. So the
guard defended a condition the probe had already ruled out, while catching the
one it should tolerate — a transient failure microseconds after the probe's own
call succeeded.

Policy: unbounded retry with capped backoff (0.5s initial, doubling, clamped at
8s). The issue's original lean was bounded-then-inert; it was retracted on the
thread because this file already answered the question the other way in
`probe_link_backend`'s inconclusive-probe arm — picking the inert verdict
"leaves the link list permanently empty even once the bus recovers ... which
beats an inert service". A bound would put the user back in exactly the
reported state whenever networkd takes longer than the bound to appear, i.e.
this bug on a longer fuse. The numbers match wifi's `PROBE_RETRY` (#634)
deliberately: same failure class, same journal cadence.

Observability was the one requirement that survived the retraction. Every
failed attempt logs at `error!` with `attempt`/`retry_in_secs`, and a retry
that lands logs `startup refresh RECOVERED` at `warn!` with `attempts=N`, so a
successful retry never erases the evidence that the path fired. Logging every
attempt rather than every Nth is safe because the backoff caps at 8s: a
permanently unreachable networkd stays loud instead of decaying into a silent
poll, and it cannot become the 2s flood the old comment feared. A first-try
success stays silent, so healthy hosts are unchanged.

The retry decision is the pure function `RefreshRetry::step`, so it is testable
with no bus; `seed_links` drives it and owns the logging. `GiveUp` stays
expressible and tested so a bounded policy remains one field away, but the
shipped default is unbounded and a test asserts it. The false justifying
comment is replaced, and a module-doc section records why a failure here is
transient by construction.

Closes #621

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
vibechoom added a commit that referenced this pull request Jul 30, 2026
…ilently at 4 Hz (#653) (#668)

`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]>
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]>
vibechoom added a commit that referenced this pull request Jul 31, 2026
…tautological shipped-policy tests (#675)

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]>
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.

feat(wifi): make backend selection re-entrant so a transient probe failure recovers without a restart (#609 follow-up)

1 participant