fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) - #634
Merged
Merged
Conversation
…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
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]>
This was referenced Jul 30, 2026
Closed
Merged
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]>
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]>
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]>
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.
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'sselect_backendturnedErr(ProbeError)— "I could not ask the bus" — intoBackendChoice::None— "the bus answered and neither daemon is present".Service::startcalled 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 onlysystemctl --user restart trollshellbrought 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
Noneverdict, or a runtime iwd↔NM switch — that's #633.What changed
probe_backenditself 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 ranrt.block_on(...)on the calling thread duringService::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 singlespawn_supervisedclosure, the shapenetworkd::NetworkdService::start(networkd.rs:305-368) already uses: one task owns the lifecycle and decides internally, off the main thread, sostart()returns immediately.Retry only while the probe is inconclusive (
Err). Commit immediately on anyOk(_)— includingOk(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.backendis aMutable<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, …) seesNoneuntil then — indistinguishable from the pre-discovery startup window that already exists today._ownership/_nm_agentbecameArc<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, andspawn_superviseddoesn't restart a clean completion, so a backend is never started twice.Incidental second fix, not claimed in the title
Pre-PR,
select_backendranrt.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 sequentialhytte_bus::calls, each fast-failing then waiting up to 5s inwait_for_reconnect), and up to ~50s against a wedged peer (two full 25s timeouts, whichis_transient_zbus_errordoesn'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.error!, withattemptandretry_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.wifi: backend probe RECOVEREDatwarn!withattempts=N.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").backend probe RECOVEREDtoken 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-289already decided this exact question the other way:That transfers wholesale, because both Wi-Fi watchers self-heal the same way —
wifi_nm.rs's discovery retries every 5s andwatcher.rshas its'discoveryloop. 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 aterror!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.GiveUpand its tests stay in the code, so a bounded policy remains one field away (max_attempts: Some(n)); only the shipped default is unbounded, andshipped_policy_is_unbounded_and_starts_promptlyasserts that. The same question is open on #621 fornetworkd'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)], notsystem-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-services→ 671 passed, 0 failed. Reintroducing the original bug (Err(_) => ProbeStep::Commit(BackendChoice::None)):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):Both edits restored afterwards and verified byte-identical with
diffagainst saved copies, then re-run clean.Gates
cargo clippy -p hytte-services --all-targets --features system-tests -- -D warnings— clean (--all-targetsso the new#[cfg(test)]code is linted;RUSTC_WRAPPERunset 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.mdgoes 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::434-437currently tells a verifier "the backend probe still runs only once at startup … that re-probe gap is deliberately untouched and is tracked as feat(wifi): make backend selection re-entrant so a transient probe failure recovers without a restart (#609 follow-up) #613". After this merge the probe retries, and feat(wifi): make backend selection re-entrant so a transient probe failure recovers without a restart (#609 follow-up) #613 is closed.:463-472's fix(wifi): a failed backend probe is no longer indistinguishable from "no backend" (#607) #609 item now expects the wrong outcome: it directs the verifier to look for anerror!pointing atsystemctl restart, when the expected result is now recovery.Live-verify
CI cannot exercise a transient bus failure — nothing in
nix flake checkcan makeListNamesfail and then succeed. This needs a manual check on a real session:systemctl --user restart trollshellimmediately aftersystemctl restart NetworkManager, or start it early in a fresh boot's session).INCONCLUSIVEline (withattempt=andretry_in_secs=) followed by oneRECOVEREDline carryingattempts=.wifi: no Wi-Fi backend present — service is inactiveshould still appear once, promptly, with no retry lines —Ok(None)must commit immediately.🤖 Generated with Claude Code