fix(networkd): retry a transient startup refresh failure instead of going inert (#621) - #645
Conversation
…oing inert (#621) 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]>
Adversarial reviewCI cannot exercise this change — nothing in VerdictSound, non-blocking. The diagnosis is correct, the premise holds under checking, the retry loop cannot spin, the 8s ceiling is real, and the state a retried refresh publishes is byte-identical to a first-try one. Two Low findings, both about things the PR says rather than things it does: a shipped-policy test that does not check the property it is named for (and which #634 does check), and a new comment that mischaracterizes the loop it sits above. Neither is a reason to hold the merge. Finding 1 — the shipped-policy tests do not pin either property the log-cadence argument rests onSeverity: Low (maintenance hazard, plus a one-sided drift from #634).
Failure scenario. A future maintainer edits Drift from #634: Refutations I tried and why they failed:
Suggested fix — two lines in Finding 2 — the new comment above the 2s loop is not true of the loop it describesSeverity: Low (comment accuracy). Directly the "is the replacement comment true?" question.
Concrete consequence. networkd answers the probe; Refutations I tried:
Suggested fix: reword to the true predicate (the 2s loop covers every refresh failure after the first successful seed, established stream or not; #621 is specifically about never getting past the seed), and add one line to the live-verify section noting that a post-seed failure surfaces as Related, recorded as considered rather than as an objection: because Note — unbounded plus no re-election, informational
That is the accepted cost of the retraction's policy and I am not arguing against it. But wifi has #633 tracking exactly this (re-election needs a cancellation primitive that does not exist). networkd has no equivalent issue. If the two subsystems are to stay paired, they should be paired in the backlog too, not only in their constants — worth filing the networkd half. Attacked and found clean
|
…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]>
…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]>
Closes #621
Refs #607, #613, #634
The bug
networkd.rs'sLinkBackend::Networkdarm seeded the link list with onerefreshandreturned if it failed — before the retry loop, so theloop { listen(…) }below was unreachable from that path. One unluckyrefreshat startup ended the task for the whole process lifetime:sourcestayed
LinkSource::Unknown, the network panel showed "no link manager hasanswered yet" forever, and
systemctl --user restart trollshellwas the onlycure.
The comment justified the
returnwith "networkd isn't running on this hostand no NM is present either". That condition cannot hold here: this arm is
reached only after
probe_link_backendhas established that networkd isthe backend. The genuinely-absent case is
LinkBackend::None, handledseparately and correctly publishing
LinkSource::Unavailable. So the guarddefended against something the probe had already ruled out, while catching the
one thing it should tolerate — a transient failure microseconds after the
probe's own call succeeded (networkd restarting, a D-Bus hiccup,
ServiceUnknownduringsystemctl restart systemd-networkd).The policy: unbounded retry, kept observable
Unbounded retry with capped backoff. Attempt 1 is immediate; each retry
waits 0.5 s doubled per elapsed attempt, clamped at 8 s, settling into one
refresh every 8 s for as long as networkd stays unreachable. Once a refresh
lands, the existing
listenloop runs exactly as before.The issue's original lean was bounded retry then inert; it was retracted on
the thread, and the counter-example is in this very file.
probe_link_backend'sinconclusive-probe arm already decided the same question the other way:
That makes unbounded the conservative option here, not the adventurous one —
it is what this file already does on the sibling path a few dozen lines above.
It applies even more directly than it does for wifi: a bound would put the user
back in exactly the state #621 reports whenever networkd takes longer than the
bound to come up, i.e. this bug on a longer fuse. And "the give-up is logged, so
a wrong bound is visible" argues for having a bound from the visibility of the
bound being wrong.
This also keeps the two subsystems consistent with #634, which landed the
same unbounded-with-capped-backoff shape for the wifi backend probe. The numbers
are copied from its
PROBE_RETRYdeliberately, and I read the constant ratherthan trusting the description —
wifi/mod.rs'sPROBE_RETRYismax_attempts: None, initial: 500ms, max_backoff: 8s, exactly whatSTARTUP_REFRESH_RETRYnow uses. Same failure class, same journal cadence, sosomeone debugging one path while reading the other's journal finds one
behaviour, not two.
What is deliberately not imported from #634: its backend-reselection
machinery. This arm has already elected networkd, so there is no verdict to
re-resolve —
refresheither read the links or could not, and "could not" isnever a fact about the host here.
Log cadence
The one requirement that survived the retracted lean is that the retry stays
observable: a quiet self-heal trades a visible permanent bug for an
invisible intermittent one, which is worse for exactly the reporter's situation
(nothing in the UI hinted anything had failed).
error!withattempt,retry_in_secsandthe error — deliberately every attempt rather than every Nth, matching fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) #634's
judgment and for the same reason: the backoff caps at 8 s, so a permanently
unreachable networkd stays loud in the journal instead of degrading into a
silent poll. It cannot become the 2 s flood the old comment worried about.
networkd: startup refresh RECOVEREDatwarn!withattempts=N, so a successful retry never erases the evidencethat the path fired. Grep tokens are
startup refresh FAILED/startup refresh RECOVERED, parallel to fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) #634'sbackend probe was INCONCLUSIVE/backend probe RECOVERED.attempt == 1prints nothing,so healthy hosts see no new lines at all.
The
listenloop's own fixed 2 s retry is untouched: that path covers aconnection that had been established and dropped, it already retried, and it
is not what #621 is about. Noted in a comment so the two cadences don't read as
an oversight.
What changed
One file,
crates/hytte-services/src/networkd.rs:RefreshStep/RefreshRetry/STARTUP_REFRESH_RETRY— the retry decisionis the pure function
RefreshRetry::step(&outcome, attempt) -> RefreshStep,so it is unit-testable with no bus.
GiveUpstays expressible and tested, soa bounded policy remains one field away (
max_attempts: Some(n)); onlythe shipped default is unbounded, and a test asserts that. It is constructed
on the
max_attempts: Some(_)path, so clippy does not flag it as dead — noallowwas needed anywhere in this PR.seed_links(policy, …) -> booldrives it and owns the logging.falsemeansthe policy stopped asking, which the shipped policy never does.
LinkBackend::Networkdarm calls it instead ofrefresh-then-return.here is transient by construction; a module-doc section records the same, next
to the existing network interfaces do not show up in panel #80 and network panel says "Offline / 0 interfaces" when it simply has no link manager to ask (#607 split) #608 sections.
No behaviour change on any other path:
LinkBackend::NetworkManagerandLinkBackend::Noneare untouched, and a host where the first refresh succeedstakes the identical code path it did before, with no new log lines.
Tests
6 new tests, all hermetic (plain
#[cfg(test)], nosystem-tests, no bus):a_refresh_that_landed_proceeds_immediately,a_failed_startup_refresh_retries_instead_of_ending_the_task,a_bounded_policy_gives_up_once_its_budget_is_spent,backoff_doubles_and_clamps_to_the_ceiling,the_shipped_policy_never_goes_inert,the_shipped_policy_starts_promptly_and_stays_audible.Falsification. With the fix in place,
cargo test -p hytte-services networkd→ 23 passed, 0 failed. Reintroducing the bug (
Err(_) => RefreshStep::GiveUp,i.e. "go inert on any failure"):
The edit was reverted afterwards with
git checkout --, andgit diff HEADconfirmed empty before pushing.
What the hermetic tests cannot cover is the wiring — that
seed_linksiswhat the
Networkdarm actually calls, and that a real transient D-Bus failurerecovers end-to-end. That needs a live bus; see live-verify below.
Gates
cargo clippy --workspace --all-targets --features system-tests -- -D warnings— clean, exit 0, with
RUSTC_WRAPPERunset so sccache cannot skip lintemission. (The first run caught a real
clippy::unnecessary_wrapson a testhelper that always returned
Ok; fixed by making it a local binding ratherthan silencing the lint.)
cargo test --workspace(hermetic) — exit 0;hytte-serviceslib 678 passed,0 failed.
nix fmt— 385 files processed, 0 changed.Live-verify
CI cannot exercise a transient bus failure — nothing in
nix flake checkcanmake networkd's
ListLinksfail and then succeed. Needs a real networkd host:systemctl restart systemd-networkdand, in the samemoment,
systemctl --user restart trollshell(or start the shell early in afresh boot's session, before networkd has settled).
shell restart — pre-fix it would sit on "no link manager has answered yet"
indefinitely.
FAILEDline (withattempt=andretry_in_secs=)followed by one
RECOVEREDline carryingattempts=.refresh succeeds, expect no
startup refreshlines at all and the linklist populated as before.
networkd: no link backend available; service inertshould still appearonce, promptly, with no retry lines — that is the
LinkBackend::Nonearm andit is untouched.
Adjacent, not fixed here
Noted for follow-up rather than widening this diff:
RefreshRetryduplicates the shape ofwifi::RetryPolicy(fix(wifi): retry an inconclusive backend probe instead of latching "no backend" (#613) #634). Sharing onepolicy type would need
wifi/mod.rsedits (itsstepis typed toResult<BackendChoice, ProbeError>where this one is generic over theoutcome), which is outside this fix's lane. The doc comments cross-reference
each other so the two can't silently drift; happy to file it if wanted.
listenloop's fixed 2 s retry has no backoff and no ceiling on itswarn!cadence, so an established-then-dropped networkd connection does logevery 2 s indefinitely. Pre-existing and out of scope here.
🤖 Generated with Claude Code