Skip to content

network: detect a wedged discv5 socket (operator health check + boot-node /healthz) - #2982

Open
iurii-ssv wants to merge 3 commits into
stagefrom
feat/discv5-health-signal
Open

network: detect a wedged discv5 socket (operator health check + boot-node /healthz)#2982
iurii-ssv wants to merge 3 commits into
stagefrom
feat/discv5-health-signal

Conversation

@iurii-ssv

@iurii-ssv iurii-ssv commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

A discv5 socket can wedge — stay bound but stop being drained — leaving discovery silently dead while the process still looks healthy. #2979 documents a boot node that sat like this for ~20 days undetected. Neither node type surfaces it today: the operator's p2pNetwork.Healthy() only checks a discovery-bootstrap flag (a runtime wedge never trips it, since the bootstrap loop keeps running and just yields nothing), and the boot node's HTTP handler always returns 200.

#2980 removes one cause of the wedge on the operator (the blocking Unhandled send); this PR adds the missing detection, so a wedge from any cause becomes a self-correcting restart.

Approach

One shared, cause-agnostic signal — "how long since the discv5 socket was last read" — with actuation scoped per node type.

  • TimedConn (network/discovery): wraps the socket and stamps the last successful read; StaleFor(d) is the wedge signal. Both node types wrap the conn they hand to ListenV5.
  • Operator: the post-fork listener (the one that drains the socket) gets the wrapped conn; DiscoveryStale feeds p2pNetwork.Healthy(), so the existing hprobe watchdog restarts the node on a wedge. No routing-table check here — a stale-but-populated table would mask it on an operator.
  • Boot node: a fail-closed /healthz returns non-200 when the routing table has been empty past a cold-start grace, or — while the table is populated — the socket has gone unread (discv5 revalidates its peers, so a populated-but-unread socket is a wedge; an empty/quiet table produces no such traffic and is judged only by the grace). Empty-table is the boot node's definitional health (the 0-vs-72/102 datapoint in boot-node: discv5 can wedge silently — no health signal tied to discovery actually working #2979).

Grace values: 3 min read-staleness (both node types); 10 min empty-table cold-start (boot node only).

Notes

Tests

  • TimedConn: seeded-not-stale, stale boundary (injected clock, no real sleeps), read-stamps, errored-read-doesn't-stamp.
  • Operator: DiscoveryStale; TestP2PNetwork_Healthy gains discovery wedged / ready with live discovery cases — the existing nil-disc cases stay green, proving the guard.
  • Boot node: /healthz across populated+fresh, populated+wedged, empty-within-grace (incl. the quiet-socket regression), and empty-past-grace.

Closes #2979. Full discovery + p2p + boot_node suites pass, discovery also under -race.

Downstream infra PRs for the boot-node side of this (what makes the boot node self-healing)

  • ssvlabs/charts#183 — adds the optional livenessProbe to the boot-node chart (inert by default).
  • ssvlabs/gitops-production#881 — adopts it on the three boot nodes (draft; blocked on a boot-node image from this PR that serves /healthz).

Merge order: #2980 → this → build image → ssvlabs/charts#183 → ssvlabs/gitops-production#881.

@iurii-ssv
iurii-ssv requested review from a team as code owners August 6, 2026 15:22
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds socket-read tracking so operator and boot-node health checks can detect wedged discv5 listeners.

  • Wraps discv5 UDP connections in an atomic last-read tracker.
  • Incorporates discovery staleness into operator health.
  • Adds a boot-node /healthz endpoint combining routing-table and socket state.
  • Adds focused tests for timestamping and health-state transitions.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains established.

No blocking failure remains.

Important Files Changed

Filename Overview
network/discovery/timed_conn.go Adds an atomic timestamping wrapper around successful discv5 UDP reads.
network/discovery/dv5_service.go Routes the post-fork listener through TimedConn and exposes discovery staleness.
network/p2p/p2p.go Extends operator health reporting to reject stale discovery sockets.
utils/boot_node/health.go Implements boot-node health evaluation from routing-table occupancy and socket-read freshness.
utils/boot_node/node.go Wires TimedConn into the boot-node listener and serves the new health endpoint.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    UDP["discv5 UDP socket"] --> TC["TimedConn records successful reads"]
    TC --> OP["Operator DiscoveryStale"]
    OP --> PH["p2p Healthy"]
    PH --> OW["Operator watchdog restart"]
    TC --> BH["Boot-node health check"]
    RT["Routing table state"] --> BH
    BH --> HZ["/healthz"]
    HZ --> BL["Boot-node liveness restart"]
Loading

Reviews (2): Last reviewed commit: "utils/boot_node: add /healthz tied to di..." | Re-trigger Greptile

Comment thread utils/boot_node/health.go Outdated
Comment thread utils/boot_node/node.go Outdated
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.05882% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.3%. Comparing base (2b6a15f) to head (a16e6da).

Files with missing lines Patch % Lines
utils/boot_node/health.go 64.0% 9 Missing ⚠️
utils/boot_node/node.go 0.0% 8 Missing ⚠️
network/discovery/local_service.go 0.0% 2 Missing ⚠️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@iurii-ssv

Copy link
Copy Markdown
Contributor Author

@greptile pls re-review

@ovidiu-ssv-labs ovidiu-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, well-reasoned design: the socket-read timestamp is a genuinely cause-agnostic wedge signal, it is wired to the only listener that actually drains the socket, and greptile's 'quiet socket' P1 is properly fixed by the populated-table gate (verified against geth's 3s revalidation interval, not just the comment thread). The one thing worth changing before merge is the operator path, which cannot distinguish 'wedged' from 'never received any UDP' and will crash-loop a node that restarts while its bootnodes are unreachable; the rest are minor robustness/observability/test-wiring points. [verdict: with_fixes]

Comment thread network/p2p/p2p.go
Comment thread network/discovery/dv5_service.go
Comment thread network/discovery/timed_conn.go
Base automatically changed from fix/discv5-unhandled-wedge to stage August 10, 2026 09:59
@iurii-ssv
iurii-ssv force-pushed the feat/discv5-health-signal branch from c9d0cc1 to b9f0d08 Compare August 10, 2026 11:37
@iurii-ssv
iurii-ssv force-pushed the feat/discv5-health-signal branch from b9f0d08 to a16e6da Compare August 10, 2026 13:09
Comment thread utils/boot_node/health.go
// loads seed nodes from the persistent enode DB straight into the table, so
// AllNodes() can be >0 before the socket has been read once — and StaleFor stays
// disarmed until that first read. Those seeds then fail revalidation and age out,
// the table empties, and the empty-table grace trips instead. A runtime wedge

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fear the fallback described here doesn't hold under a dispatch-loop wedge.
Table entries are only deleted in tableRevalidation.handleResponse, and revalidation itself goes through the wedged dispatch goroutine (initCall blocks on t.callCh, which that goroutine drains), so the seeds never age out, AllNodes() stays >0, lastNonEmpty keeps refreshing, and /healthz stays green indefinitely.

The dispatch-wedge case is still caught (a prior read has armed StaleFor), but a stall where the socket never yields a read (kernel/conntrack-level) lands exactly in populated-table + disarmed-socket = healthy forever. Worth correcting the comment at minimum, or considering a self-probe: write a junk packet to the socket's own LocalAddr() and require LastRead() to advance, which covers it deterministically and independently of peers.

Comment thread network/p2p/p2p.go
// A wedged discv5 socket leaves discovery silently dead while bootstrap keeps
// looping; surface it so the hprobe watchdog restarts the node. n.disc is nil
// in tests and briefly at startup, hence the guard.
if n.disc != nil && n.disc.DiscoveryStale(discoveryStaleGrace) {

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this lead to a restart loop on a healthy node that just has no inbound UDP?
After a single successful read ever, 3 minutes of inbound silence fails Healthy: e.g. inbound UDP blocked upstream (firewall/NAT/conntrack change) while the node keeps operating fine over its established libp2p TCP peers; the restart drops every peer connection and can't fix the network.

A total blackhole self-limits (the arm resets on restart), but a partial one the odd scan packet or NAT-refreshed reply arriving each boot re-arms every cycle and loops indefinitely.
Maybe stamp writes in TimedConn too and only declare a wedge when reads AND writes are both stale: all v5 sends funnel through the same dispatch goroutine, so a real wedge freezes both, while an inbound blackhole leaves writes flowing (nursery bootnodes are re-pinged every refresh).

Comment thread utils/boot_node/health.go
func (h *bootNodeHealth) check() error {
now := h.now()
if len(h.lister.AllNodes()) > 0 {
h.lastNonEmpty.Store(now.UnixNano())

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that the empty-table clock is driven by probe arrivals rather than table state: if probing pauses (kubelet restart, or the livenessProbe manifest lands in a later rollout than the image), the first resumed check compares now against a stale observation and can fail with no effective grace.

Restarting a healthy pod. Conversely, a table flapping in and out of empty faster than the probe period resets the clock every time, so the grace never accumulates. Might be worth sampling the table from a small owned ticker and having check() read only the sampled state.

return false
}
age, ok := dvs.socketConn.ReadStaleness()
recordDiscoveryReadStaleness(dvs.ctx, int64(age.Seconds()))

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we distinguish the never-read state in the gauge?
Right now "read 200ms ago" and "never read since boot" both record 0 and never-read is precisely the state the health check deliberately ignores, so it's the one you'd want visible to a human for alerting.

A sentinel (-1) or a companion read-ever metric would keep it distinguishable. Also, since this only records when Healthy() reaches the staleness check, the series goes quiet whenever isReady/discoveryFailed short-circuit earlier: i.e. exactly when discovery is broken.

// shows up as a last-read timestamp that stops advancing. StaleFor turns that
// into a liveness signal, used by both the operator and boot nodes.
//
// Only ReadFromUDPAddrPort is overridden; writes, Close and LocalAddr fall

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth stating the scope here: only the post-fork listener reads through TimedConn, the pre-fork listener reads SharedUDPConn's buffer, so a pre-fork dispatch stall moves neither health signal.

The known blocking cause is gone post-#2980, but as written the doc reads as if the wedge signal covers discovery generally.

Comment thread utils/boot_node/health.go
func (h *bootNodeHealth) handler() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if err := h.check(); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)

@momosh-ssv momosh-ssv Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need /healthz unauthenticated on the public ENR-advertised port?
Every request takes the discv5 table mutex via AllNodes() and allocates the full node slice, there's no method filtering, and the error body discloses internal state.

Maybe memoize check() for ~1s, reject non-GET/HEAD with 405, and return a fixed body with the reason logged instead — or serve it on a pod-internal listener.

@ovidiu-ssv-labs ovidiu-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔁 Re-review vs c9d0cc1: 2 fixed, 1 partial, 0 still open, 3 new. All three prior findings were addressed — the never-read arming guard, the ListenV5 wiring test, and the staleness gauge + warn log are all in and the packages build and test green. Two things remain: the operator's arming latch is permanent, so momosh-ssv's restart-loop case (blocked inbound UDP after a prior read) is genuinely still open; and the boot node's empty-table branch ignores the socket signal, turning a discv5 protocol-ID/config mismatch into a permanent CrashLoopBackOff that also destroys the /p2p debug surface. The core design — a cause-agnostic socket-read timestamp wired to the only listener that actually drains the socket — is sound and well documented. [verdict: with_fixes]

Comment thread network/p2p/p2p.go
// A wedged discv5 socket leaves discovery silently dead while bootstrap keeps
// looping; surface it so the hprobe watchdog restarts the node. n.disc is nil
// in tests and briefly at startup, hence the guard.
if n.disc != nil && n.disc.DiscoveryStale(discoveryStaleGrace) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 1 · [IMPORTANT] [still open] The operator's arming latch is permanent, so lost inbound UDP still produces a self-sustaining restart cycle

Status: independently verified as still open. This confirms momosh-ssv's question on this line, with the mechanism traced end to end.

Mechanism. read is a one-way latch with no reset path:

// network/discovery/timed_conn.go:50-57
func (c *TimedConn) ReadFromUDPAddrPort(b []byte) (n int, addr netip.AddrPort, err error) {
	n, addr, err = c.UDPConn.ReadFromUDPAddrPort(b)
	if err == nil {
		c.lastReadUnixNano.Store(c.nowFn().UnixNano())
		c.read.Store(true)   // set once, never cleared for the process lifetime
	}
	return n, addr, err
}

Once set, ReadStaleness() returns ok=true forever, so Healthy() at p2p.go:568 fails on any 3-minute inbound-UDP drought, regardless of cause. startHealthProber treats a failed round as terminal — it returns an error, the node exits non-zero, and the orchestrator restarts it.

Why the fix for prior finding 1 does not cover this. The never-read guard only exonerates a process that has never read a packet. A node running normally for hours has read=true. If inbound UDP then stops — upstream firewall/security-group change, NAT/conntrack rotation killing reply flows, an ISP-level UDP filter — the node keeps operating perfectly over its established libp2p TCP peer set, but exits within ~5 minutes.

Why it self-sustains rather than self-limiting. The stamp is taken at the socket read, before any decode: geth's UDPv5.readLoop reads every datagram on the port and only then attempts decode. So a port-scan probe, a stray discv4 packet, or a single NAT-refreshed reply is enough to flip read to true. Anything short of a perfect blackhole re-arms every boot, settling into a ~5-minute exit cycle.

Why it matters. Each cycle tears down every libp2p connection, forces full re-handshake/re-subscription, and risks missed attestation/sync-committee duties — direct penalty exposure on a validator node. The restart cannot fix an upstream UDP filter, so the loop is unbounded.

On the suggested fix in the thread (stamp writes). Does not discriminate — in the #2980 wedge, discv5 keeps sending (dispatch retries) even though reads are stale; in the blocked-inbound-UDP case, discv5 also keeps sending. Writes advancing with reads stale is the signature of both.

What does discriminate: not the socket — the impact. A genuine discv5 wedge progressively costs the node peers (churn with no replacement); an upstream UDP filter on a node with a warm, healthy TCP peer set does not.

Suggested fix: Require corroborating evidence that the node is actually impaired before restarting:

if n.disc != nil && n.disc.DiscoveryStale(discoveryStaleGrace) {
	peers := len(n.host.Network().Peers())
	if peers < n.cfg.MinPeers {
		n.logger.Warn("discv5 socket wedged and peer set degraded", ...)
		return fmt.Errorf("discv5 socket not drained for >%s and only %d peers (discovery wedged)", discoveryStaleGrace, peers)
	}
	n.logger.Error("discv5 socket not drained, but peer set still healthy — not restarting", zap.Duration("grace", discoveryStaleGrace), zap.Int("peers", peers))
}

Alternative: raise discoveryStaleGrace substantially (discv5 revalidates at a 3s PingInterval, so even 30 minutes is a 600x margin) and cap self-inflicted restarts. Either way, add a test pinning that a permanent external UDP fault does not restart-loop.

Comment thread utils/boot_node/health.go
}
return nil
}
if emptyFor := now.Sub(time.Unix(0, h.lastNonEmpty.Load())); emptyFor > h.emptyTableGrace {

@ovidiu-ssv-labs ovidiu-ssv-labs Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 2 · [IMPORTANT - debatable] Boot node: the empty-table branch ignores the socket signal, so a protocol-ID/config mismatch becomes a permanent CrashLoopBackOff

Mechanism. check() splits on table population; the empty-table branch never consults h.socket:

// utils/boot_node/health.go:74-87
if len(h.lister.AllNodes()) > 0 {
	h.lastNonEmpty.Store(now.UnixNano())
	if h.socket.StaleFor(h.readStaleGrace) { ... }   // socket consulted
	return nil
}
if emptyFor := now.Sub(...); emptyFor > h.emptyTableGrace {
	return fmt.Errorf("discv5 routing table empty for >%s", h.emptyTableGrace)  // socket ignored
}

So AllNodes() == 0 for 10 minutes fails closed even when the socket is demonstrably being drained.

Concrete break. The boot node's table is populated exclusively by inbound traffic (no Bootnodes configured), and geth only adds an inbound peer after a completed handshake; a V5ProtocolID mismatch (wrong Network option, or a protocol-ID rollout landing on the boot node ahead of the fleet) makes every packet decode-fail before reaching the table. The socket is read normally (TimedConn is fresh), but AllNodes() stays 0 forever. /healthz then 503s every 10 minutes and the livenessProbe restarts the pod indefinitely — a restart cannot fix a config mismatch.

Why this is worse than the pre-PR silent failure. The boot node's only diagnostic surface is /p2p on the same HTTP server. Under CrashLoopBackOff that endpoint is unreachable for most of the cycle, so the operator is left with a flapping pod and no way to observe why the table is empty. A fresh deployment whose ENR hasn't been distributed yet hits the same flap, and CrashLoopBackOff backoff (up to 5 min) means it may be down at the moment the first cold-bootstrapping node arrives — the exact failure #2979 exists to prevent.

Ruled out: 'populated table full of dead peers during a network outage' does NOT false-positive — geth's tableRevalidation drains failed peers well inside the 3-minute read grace. Only the empty-table branch has this gap.

Suggested fix: Fold the socket signal into the empty-table branch — fail closed only when BOTH the table is empty AND the socket is undrained:

if emptyFor := now.Sub(time.Unix(0, h.lastNonEmpty.Load())); emptyFor > h.emptyTableGrace {
	if age, everRead := h.socket.ReadStaleness(); everRead && age <= h.readStaleGrace {
		h.logger.Error("discv5 routing table empty while socket is being drained — check DiscoveryProtocolID / network config", zap.Duration("empty_for", emptyFor), zap.Duration("read_age", age))
		return nil
	}
	return fmt.Errorf("discv5 routing table empty for >%s and socket undrained", h.emptyTableGrace)
}

Needs socketDrainState widened to expose ReadStaleness() (time.Duration, bool) — *TimedConn already has it. Add two test cases: empty table + fresh socket past the empty grace (healthy, with error log), and empty table + never-read past the empty grace (503).

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.

3 participants