Skip to content

fix(networkd): a transient refresh failure at startup returns before the retry loop, latching LinkSource::Unknown for the process lifetime #621

Description

@vibechoom

Found by an adversarial combined-tree audit of 9c9a1d8..269a6d4; survived two independent refuters and re-verified in 269a6d4.

Filing rather than fixing — the current behavior is deliberate and commented as such, so changing it is a design call, not a chore. See the design question at the bottom.

Where

crates/hytte-services/src/networkd.rs:340-345, inside the LinkBackend::Networkd arm:

if let Err(e) = refresh(&links_writer, &primary_writer, &source_writer).await {
    tracing::info!(error = ?e, "networkd unreachable at startup; service inert");
    return;
}
loop {
    match listen(&links_writer, &primary_writer, &source_writer).await {
        ...
    }
    tokio::time::sleep(Duration::from_secs(2)).await;
}

The return is before the retry loop. A single failed refresh at startup ends the task for the entire process lifetime. source stays LinkSource::Unknown, so the panel shows "No link manager has answered yet" forever and no retry ever happens — the 2 s retry loop below is unreachable from this path.

Why that's a bug and not just a policy

The surrounding comment justifies it:

isn't running on this host and no NM is present either — log once at info and stay inert rather than hammering dbus in a 2s retry loop for the rest of the process lifetime.

But this code is inside the LinkBackend::Networkd arm — reached only after the backend probe already determined networkd is the backend on this host. The "networkd isn't running here" case is LinkBackend::None, which is handled separately at :357-361 and correctly publishes LinkSource::Unavailable.

So the guard defends against a condition that the probe has already ruled out, while catching the condition it should tolerate: a transient refresh failure microseconds after the probe's own call succeeded (networkd restarting, a D-Bus hiccup, ServiceUnknown during systemctl restart systemd-networkd). One unlucky moment at boot and the network panel is permanently wrong until the shell is restarted.

Note this is the same class as #613 (wifi backend selection not re-entrant, so a transient probe failure needs a restart to recover) — one layer over, in networkd rather than wifi_backend. #613 is currently held deliberately: #609's error! line exists to measure how often the transient failure fires, and shipping a retry destroys the measurement before it's taken. That reasoning applies to wifi_backend; whether it also applies here is the open question.

A related refuted claim, recorded so nobody re-files it: the audit also proposed that LinkSource::Unavailable is only reachable from a failed networkd read. Two independent refuters killed that on the source — Unavailable is published from the LinkBackend::None arm as a positive finding, as documented. That part is fine.

Severity

Medium. Narrow trigger (a failure in a window of microseconds), but the consequence is total and silent for the rest of the session: the link panel is stuck on an indeterminate state with no recovery short of restarting the shell.

Design question for @annikahannig

Two defensible shapes, and I don't want to pick unilaterally:

  1. Retry here too — move the refresh failure into the loop so it retries every 2 s like listen already does. Simple, symmetric with listen's existing behavior, and the "don't hammer dbus" worry is largely moot because the probe already told us networkd is present. Risk: on a pathological host we do poll a dead service every 2 s forever.
  2. Bounded retry, then inert — try N times with backoff, then give up and log at error! (not info!) so the give-up is visible in journalctl, matching the pattern fix(wifi): a failed backend probe is no longer indistinguishable from "no backend" (#607) #609 established for an inconclusive probe.

I lean (2): it fixes the transient case, keeps a ceiling on D-Bus traffic, and — like #609's error! line — leaves evidence in the log that this path fired, which is what would let us close #613 on data rather than guesswork. Happy to build either.

Refs #608, #609, #610, #613.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions