Skip to content

net/netlink: a failed link lookup is not an absent interface - #668

Open
Philip Lombardi (plombardi89) wants to merge 4 commits into
mainfrom
fix/net-netlink-classify-link-errors
Open

net/netlink: a failed link lookup is not an absent interface#668
Philip Lombardi (plombardi89) wants to merge 4 commits into
mainfrom
fix/net-netlink-classify-link-errors

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

First of three PRs splitting out the net fixes found while working through #657. This one is a bug fix and changes no lint configuration; nilerr and predeclared get enabled in the last of the three.

Force-pushed twice after review. Round 1 fixed the lookupError(nil) regression in place. Round 2 corrected commit 3's message, which stated something false about the gateway prune, and added the missing metric coverage. Commits 1 and 2 are unchanged since round 1. See "Review responses" at the bottom.

The bug

netlink.LinkByName can fail without the interface being missing: a busy socket, a permissions problem, a truncated response. link_manager.go read every such error as "not there" in twelve places — ten Ensure* paths, DeleteLink and Exists() — and drew one of two wrong conclusions.

DeleteLink drew the worse one. Any lookup error returned nil, so a caller that asked for an interface to be removed was told it had been, when nothing was deleted.

The ten Ensure* paths drew the other. On a lookup failure they fell through to creation, turning a transient error into an attempt to create an interface that already exists. Self-correcting via EEXIST, but noisy on a healthy system. Five of those ten turn out to have no production caller at all (see below); the live ones span IPIP, GENEVE, VXLAN, WireGuard and the dummy interface.

The fix defers to isLinkGoneError, which already existed in this file (link_manager.go:844) and was already used for exactly this distinction by EnsureBridgePortMTUs and EnsureBridgePodMTUs.

A reviewer independently confirmed the helper is complete for netlink@v1.3.1ENODEV (link_linux.go:2030), zero-message (:2039) and the EINVAL dump fallback (:1904) all yield LinkNotFoundError. So there is no gap that would make Ensure* permanently refuse to create.

Four commits

  1. route link calls through seams, no behaviour change — 3 seam vars following the netlinkRuleList convention in route_table_setup.go:20-22; 36 call sites rerouted (23 LinkByName, 10 LinkAdd, 3 LinkDel). Provably mechanical: reversing the rename and removing the var block reproduces the previous file byte for byte.
  2. a failed link lookup is not an absent interface — the fix, plus link_manager_test.go.
  3. do not prune gateway rules on a failed link lookupseparable; drop this commit alone if you disagree.
  4. count the lookup failures that are only logged — metric coverage for the two paths that swallow the condition, and removal of a redundant isLinkGoneError check in DeleteLink.

lookupError treats nil as nothing to report, and that is load bearing

isLinkGoneError(nil) is falseerrors.As returns false for a nil error (errors/wrap.go:103), and errors.Is(nil, ENODEV) reduces to nil == ENODEV (:46-48). Without the nil case it would wrap nil and return an error rendering as %!w(<nil>).

EnsureIPIPExternalInterface and EnsureGeneveInterface reach it with err == nil after deleting an interface they are about to recreate. They would delete and then abort. Both are additionally written as if err != nil { … } else { … } so control flow does not depend on the helper being total.

Scope of the DeleteLink fix: latent, not manifesting

Worth being straight about. All 8 DeleteLink production callers only log the error — V(2), Warningf or Errorf — and none propagate or retry. Five sit behind if lm.Exists(), which this PR deliberately keeps returning false on a broken lookup, so DeleteLink is not even reached from them. The one continue on the error path (site_watch_reconcile.go:631-634) skips only a success log, and a LinkDel sweep follows at :640.

The fix is correct and worth keeping as prophylaxis. It does not fix an outage anyone is currently having.

After commit 4 it is simply return lm.lookupError(err); the earlier explicit isLinkGoneError check tested the same condition the helper already tests.

Exists() keeps its bool

All six callers — tunnel_config.go:307,363,432,886, wireguard_config.go:96, mtu.go:39 — are reconcile predicates that retry, so a transient false is harmless. Commit 4 makes it count the condition as well as log it, so a node whose lookups are failing is distinguishable from one whose interfaces are legitimately absent, which a plain bool otherwise hides.

Commit 3, on honest grounds

The gateway prune tore down policy routing for interfaces that were still up whenever a lookup failed for a non-absence reason. That was wrong and is worth fixing.

But my original justification was false, and is corrected in the commit message. ReconcileExpectedInterfaces already clears both managed mangle chains and rebuilds from expected (gateway_policy_manager.go:1016-1047), then replaces configuredIfaces wholesale (:584-589); cleanupStaleIPRulesLocked flushes stale route tables off netlinkRuleList with no link lookup. So the prune is not the only cleanup, and declining to prune does not strand rules indefinitely.

Exposure is close to zero anyway: EnablePolicyRouting is false in the flag default (main.go:278), the runtime config default (:200), the ConfigMap template (01-configmap.yaml.tmpl:44) and the rendered output, and is documented as deprecated in favour of the UNBOUNDED-FORWARD chain (runtime_config.go:68-70). Both this prune's caller and ReconcileExpectedInterfaces are gated on it.

The honest cost of the trade — #671

Deferring a reconcile pass is not benign on the tunnel path. pendingBPFEntries is nil'd before configureTunnelPeers (site_watch_reconcile.go:2047); an abort leaves it nil; the caller only warns (:2066); reconcilePendingBPFEntries still runs (:2147); entries == nil becomes an empty map (tunnel_config.go:760-762); and TunnelMap.Reconcile deletes everything not in desired (tunnel_map.go:288-296). So an aborted pass withdraws every tunnel peer from the LPM trie.

This is pre-existing and unchanged in the common case, because the old code fell through to LinkAdd, hit EEXIST and aborted at the same line. The one genuinely new way to reach it is a transient lookup failure where the interface really is absent: the old code created it, the new code defers. Right trade, real cost. Filed as #671.

Known: five tested methods are unreachable — #670

EnsureIPIPInterfaceWithRemote, EnsureGeneveInterfaceWithRemote, EnsureBridge, EnsureGeneveInterfaceWithCache and EnsureIPIPInterfaceWithCache have no production caller; the tests added here are their only callers. Two further exported LinkManager methods, SetLinkNoARP and GetAddresses, have no references anywhere — 7 of 22 exported methods on the type. None of the seven is required to satisfy an interface, so none is load-bearing.

unused is enabled and cannot see any of it: rule (1.1) makes the package use exported named type LinkManager, and (2.1) makes that type use all its exported methods, so they are transitively live because the type is exported.

Two consequences for this PR's claims:

  • The Ensure* fix does not cover the CNI bridge. EnsureBridge is dead; the live bridge path is the cniBridgeLinkManager interface in mtu.go:19-24Exists/EnsureMTUWithCache/EnsureBridgePortMTUs/EnsureBridgePodMTUs — and is untouched here.
  • The *WithCache methods, which decide via cache.HasLink() and go straight to creation on a miss, retain the same bug shape sourced from a stale cache rather than a failed syscall. That is not live residual risk, because nothing calls them.

Deliberately not deleted here, to keep this PR to one idea. Tracked in #670, along with a proposal to evaluate x/tools/cmd/deadcode, which does the whole-program analysis unused declines to.

The tests fail without the fix

Measured against this branch's HEAD, in both directions:

  • Reverting the ten guards and DeleteLink: 10 failing Ensure* subtests and a failing DeleteLink.
  • Reverting only the nil case: 3 failing recreate subtests, with %!w(<nil>) in the output.
  • Removing the Exists() counter: the metric test fails.
  • The no-op cases are covered too — already-correct interfaces, already-absent interfaces — so no fix can pass by refusing to act at all.

The seams are package-level vars, so no test calls t.Parallel(): parallel subtests overwrite each other's stubs and the failures look like the code misbehaving. There is a comment saying so. This is enforced by convention, not by the compiler.

Verification

Against the pinned golangci-lint v2.13.1 (Makefile:435), not a locally-installed version.

  • go build ./... clean, make lint 0 issues, make fmt idempotent
  • go test -race ./internal/net/... all pass
  • nilerr measured alone: 14 → 13, with link_manager.go gone from the list

Risk worth naming: this touches every live tunnel-creation path on the node. The tests are unit-level with seams and cannot prove real netlink behaviour. gantry e2e, agent e2e and pxe-smoke are the actual safety net.

The first CI run after the round-2 push showed two failures, Lint and Storage Integration. Both were transient module-proxy errors during dependency download — sum.golang.org and proxy.golang.org returning INTERNAL_ERROR three minutes apart — and both died before reaching any code from this branch. Rerun is green.


Review responses

Round 2

# Finding Outcome
1 5 of 10 tested Ensure* have no production caller Confirmed. Body corrected; found 2 more (7 of 22). Deletion deferred to #670
2 DeleteLink's error return is unobservable Confirmed. Restated as latent above
3 Commit 3's rationale is factually wrong Confirmed. Message rewritten; exposure ≈ zero documented
4 Aborted pass wipes the LPM trie Confirmed. Documented above, filed as #671
m1 DeleteLink double-checks isLinkGoneError Fixed in commit 4
m2 Metric under-reports Fixed in commit 4, with a test
m3 klog in Exists() would trip the nil-fixture panic Not reproduced — see below
m4 Seams are unsynchronised globals Confirmed, noted above as convention-enforced
m5 route_manager.go:161 same shape Confirmed, benign: a documented best-effort constructor lookup

On m3. The klog.V(2) in Exists() is gated on !isLinkGoneError(err), which excludes precisely the gone errors, so LinkNotFoundError{} never reaches that format string at any verbosity. errors.As type-matches by reflection and never calls Error(). The trap is real for any future log placed on the gone path — which the fixture comment already warns about — but it is not reachable today.

Round 1

# Finding Outcome
1 lookupError(nil) non-nil regresses 2 paths Confirmed, fixed. Nil-safe helper and both functions restructured
2 Tests never enter the recreate branch Confirmed, fixed. 5 recreate cases + delete-failure + lookupError(nil)
3 misspell should flag recognises Not reproduced. No misspell block exists in .golangci.yaml, so no locale: US; "recognises" appears only in words_us.go:960, gated behind it. Verified twice at 0 issues
4 LinkNotFoundError{} would panic if formatted Confirmed. Comment added on the fixture
5 PR body mischaracterises WithCache Confirmed, corrected
6 No metric on the new error paths Confirmed. Added on the error-returning path in round 1, completed in round 2 (see m2)
7 Commit 3 is retention, not deferral Superseded by round 2 finding 3
8 Seam asymmetry needs a comment Confirmed, added

link_manager.go decides, in twelve places, whether a netlink error means
"the interface is not there" or something else. Nothing can test those
decisions while the lookup is a direct call to netlink.LinkByName, because
a unit test has no way to make a real netlink syscall fail on demand and
no privileges to make one succeed.

Add netlinkLinkByName, netlinkLinkAdd and netlinkLinkDel and route all 36
call sites in this file through them. This follows the convention already
used for netlinkRuleList, netlinkRuleAdd and netlinkRuleDel in
route_table_setup.go, which gateway_policy_manager_mock_test.go swaps and
restores under defer.

This commit changes no behaviour and is mechanical: reversing the rename
and removing the var block reproduces the previous file byte for byte. The
error classification it exists to enable is the next commit, kept separate
so it can be read on its own.

LinkAdd and LinkDel are included because the interesting assertion is
usually negative: given a transient lookup error, the manager must not go
on to create or delete an interface. LinkSetUp, LinkSetMTU, LinkSetARPOff,
LinkSetHardwareAddr and LinkList are left as direct calls, as no test
reaches them.
@plombardi89
Philip Lombardi (plombardi89) requested a review from a team August 26, 2026 15:46
netlink.LinkByName can fail without the interface being missing: a busy
socket, a permissions problem, a truncated response. link_manager.go read
every one of those as "not there" in twelve places, and drew one of two
wrong conclusions from it.

DeleteLink drew the worse one. Any lookup error returned nil, so a caller
that asked for an interface to be removed was told it had been, when
nothing had been deleted and the interface was still carrying traffic.

The ten Ensure* paths drew the other. On a lookup failure they fell
through to creation, so a transient error turned into an attempt to
create an interface that already exists. That one is self-correcting,
because the create then fails with EEXIST, but it fails noisily on a
healthy system and it covers every tunnel type: IPIP, GENEVE, VXLAN,
WireGuard, the CNI bridge and the dummy interface.

Both now go through lookupError, which defers to isLinkGoneError. That
helper already existed in this file and is already used by the bridge MTU
paths for exactly this distinction; it recognises LinkNotFoundError,
ENODEV and ENOENT.

lookupError treats a nil error as nothing to report, which is load
bearing rather than defensive. isLinkGoneError(nil) is false:
errors.As returns false for a nil error and errors.Is(nil, ENODEV)
reduces to nil == ENODEV. Without that first condition lookupError would
wrap nil and return a non-nil error rendering as "%!w(<nil>)".
EnsureIPIPExternalInterface and EnsureGeneveInterface reach it with
err == nil, having just deleted an interface they are about to recreate,
so they would delete the interface and then abort instead of recreating
it. On the GENEVE path that aborts the whole eBPF tunnel reconcile
(tunnel_config.go:253) with geneve0 already gone. Those two are also
written as if err != nil { ... } else { ... } so the control flow does
not depend on lookupError being total.

Exists keeps its bool signature. Its six callers, in tunnel_config.go,
wireguard_config.go and mtu.go, are all reconcile predicates that retry,
so a transient false is harmless there and returning an error would only
give six call sites something to log. It logs the condition itself at V(2)
instead, so a false caused by a broken lookup can be told apart from a
false caused by an absent interface.

Lookup failures increment InterfaceOperationErrors with a new "lookup"
label, alongside the existing "create" and "set_mtu", so the class of
failure this commit makes visible is visible to Prometheus too.

The tests fail without the change. Reverting the guards and rerunning
gives ten failing Ensure* subtests and a failing DeleteLink; reverting
only the nil case gives the three recreate subtests, failing with the
"%!w(<nil>)" message itself. The no-op cases are covered in both
directions, so neither fix can pass by refusing to act at all.

Not addressed here: EnsureGeneveInterfaceWithCache and
EnsureIPIPInterfaceWithCache decide via cache.HasLink, a plain map
lookup, and go straight to creation on a miss with no fallback lookup, so
those two keep the same shape sourced from a stale cache rather than a
failed syscall. The risk is low, because NetlinkCache.Refresh only swaps
its maps after both LinkList and RouteList succeed, but it is not fixed
here.
@plombardi89
Philip Lombardi (plombardi89) force-pushed the fix/net-netlink-classify-link-errors branch from ea1f5fd to 0a1658f Compare August 26, 2026 16:28
pruneStaleInterfacesLocked removes the policy routing rules for any
interface it decides has gone away, and it decided that from
`if _, err := netlink.LinkByName(iface); err == nil { continue }`. A
lookup that failed for any other reason therefore removed the mangle
rules and the routing table entry for an interface that was still up,
black-holing traffic on it until something else put the rules back.

This is the same mistake as the previous commit, in the same package, and
worse in kind: the Ensure* paths fail noisily on a healthy system, this
one silently tears down working configuration.

Gate the prune on isLinkGoneError, and log at V(2) when a lookup failure
leaves the rules in place.

On the cost of not pruning, which is smaller than it first looks.
ReconcileExpectedInterfaces already clears both managed mangle chains and
rebuilds them from the expected set, and then replaces configuredIfaces
wholesale, so orphaned rules and stale map entries are removed on that
path whatever the link lookups do; the delete() skipped here is
redundant with it. cleanupStaleIPRulesLocked likewise finds stale ip
rules from netlinkRuleList and flushes their route tables and rt_tables
entries without consulting a link at all. So this is not the only
cleanup, and declining to prune does not strand rules indefinitely.

Exposure is smaller still. EnablePolicyRouting is false in the flag
default, in the runtime config default, in the shipped ConfigMap
template and in the rendered output, and the field is documented as
deprecated and replaced by the UNBOUNDED-FORWARD chain. Both this
prune's caller and ReconcileExpectedInterfaces are gated on it, so in
any default deployment none of this code runs.

The change is still worth making: the behaviour was wrong, and the
feature is retained for backward compatibility rather than removed.

This commit is deliberately separable from the rest of the branch and can
be dropped on its own.

No direct test. GatewayPolicyManager holds ipt4 and ipt6 as concrete
*iptables.IPTables, so the loop cannot be reached without real iptables
handles, and adding an interface seam for them is a larger change than
this fix. The classification it relies on, isLinkGoneError, is covered by
link_manager_test.go.
The previous commits added an InterfaceOperationErrors{operation="lookup"}
counter, but only on the paths that return an error. The two paths that
swallow the condition were left uncounted, which is backwards: those are
the ones with nothing else to show for it.

Exists() returns a plain bool by design, because its six callers are all
reconcile predicates that retry. That makes a node whose netlink lookups
are failing indistinguishable, from the outside, from one whose
interfaces are legitimately absent. It now counts the failure as well as
logging it at V(2).

The gateway policy prune does the same. It declines to remove rules when
a lookup fails for a reason other than absence, which is the right call,
but the declined cleanup was visible only at V(2).

Both count only lookups that are not absence. An interface that is gone
is not an error and must not inflate the counter; a test pins that in
both directions, and fails if the increment in Exists() is removed.

DeleteLink loses a redundant isLinkGoneError check. lookupError already
returns nil for an absent interface, so the explicit check before calling
it tested the same condition twice.
@plombardi89
Philip Lombardi (plombardi89) added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 27, 2026
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.

2 participants