Skip to content

GO-7467 Rework quic degradation fallback - #775

Merged
requilence merged 8 commits into
mainfrom
go-7467-quic-degradation-fallback-2
Sep 1, 2026
Merged

requilence merged 8 commits into
mainfrom
go-7467-quic-degradation-fallback-2

Conversation

@requilence

Copy link
Copy Markdown
Contributor

Follow-up to #774, which shipped the feature with a detection signal that could not fire and a policy that could not be undone. Reviewed along six lenses, then three more after the rework; every fix here has a test that fails without it.

Structure

The demotion state moves out of peerservice into its own net/quicdemotion component, wired like peerobserver: registering it is the opt-in, so a server node leaves it out and dialing behaves exactly as before. That removes the five methods bolted onto the public PeerService interface and the EnableQuicDemotion call, which had to happen at a precise point in the component lifecycle — and did not, since clients enable it from their config component, whose Init runs before peerservice's. It dereferenced a nil penalty state and panicked on every account start. peerservice now only reports what a dial did and asks whether to reorder.

Signal

  • A blackholed UDP path gives up with an idle timeout, never a handshake timeout: with nothing received, quic-go measures the idle deadline from connection start and it fires at half the handshake deadline. The old check therefore never matched the case it was written for, so a fully blocked UDP path scored nothing. There is now a test that dials a real local socket which never answers, so a library bump can't silently break this.
  • Connection lifetime moves to the wall clock — the monotonic clock pauses while the machine sleeps, which made a slept-through connection look seconds old, the exact false positive the age threshold exists to prevent.
  • A connection that carried bytes counts as healthy: the pool closes idle connections after about a minute, so short-RPC peers never reached the age threshold and their strikes could only ever climb.
  • An old idle timeout is neutral, not healthy. Keepalives are on, so a working path never reaches an idle timeout; late ones are usually sleep or a network change, which must not delete a peer's history.
  • A stateless reset is evidence the path works, not against it.

Policy

  • Strikes decay after an hour and peers that can no longer affect a decision are pruned, so the state tracks recent trouble instead of growing for the life of the install.
  • A demoted peer is dialed quic-first once every ten minutes. Dialing stops at the first working scheme, so without a probe a demotion suppressed the only evidence that could lift it.
  • Demotion is suspended while yamux itself is failing, and a dial-time strike requires that yamux specifically carried the same dial — webtransport and iroh are quic-based, so succeeding on them proves UDP works. The suspension is dated rather than latched, expires after five minutes, counts only network nodes, and is cleared on reset: as a global one-way flag it could be set by a sleeping LAN peer and never cleared, silently disabling the feature for the rest of the process.
  • Dial evidence is reported once the dial is fully resolved, so a connection that is opened and then rejected (a stale address pointing at another peer) is neither a working fallback nor a strike.
  • TTLs are jittered, since a deterministic ladder leaks how many episodes an install has seen and lines devices up.

Also

The network-wide verdict is cached instead of rescanned under the lock on every dial, and globalDemotionMinPeers now governs the rule it documents rather than being hard-coded. The conn observer is stored atomically and watch is a no-op without one — a server node has none, and a nil call in a detached goroutine would take the process down. Snapshots carry a schema version so a shape change here cannot be misread by a client that persisted an older one.

Testing

go build ./..., go test ./... and go test -race ./net/... all pass. Mutation testing over the new code killed 19 of 22 injected mutants in the previous round; the survivors are covered here and each new test was verified against its mutant.

Notes for the reviewer

  • No other repo is affected: no consumer references the removed methods, PenaltySnapshot/PeerPenalty, or IsHandshakeTimeout, and nothing mocks PeerService.
  • The client wiring (anytype-heart) is not in this PR and still targets the old API; it needs repointing at quicdemotion.CName.
  • Scope worth stating plainly: this is the right answer for UDP-blocked networks, where it converges in 30-60s and removes a full dial timeout per peer. It is not a complete answer to a DPI that freezes flows — yamux here uses libp2p-TLS with no SNI, so such a flow is frozen after ~15-20KB while its dial still succeeds, and nothing currently watches yamux flow health. Pinning QUICv2 (a client-only quic.Config.Versions change that production nodes already accept) looks like the better counter for that case and is worth measuring separately.

Follow-up to #774, which shipped the feature with a detection signal that
could not fire and a policy that could not be undone. Reviewed along six
lenses; every fix below has a test that fails without it.

Structure. The demotion state moves out of peerservice into its own
net/quicdemotion component, wired like peerobserver: registering it is the
opt-in, so a server node leaves it out and dialing behaves exactly as before.
That removes the five methods bolted onto the public PeerService interface and
the EnableQuicDemotion call, which had to happen at a precise point in the
component lifecycle - and did not, since clients enable it from their config
component, whose Init runs before peerservice's. It dereferenced a nil
penalty state and panicked on every account start. peerservice now only
reports what a dial did and asks whether to reorder.

Signal. A blackholed UDP path gives up with an idle timeout, never a
handshake timeout: with nothing received, quic-go measures the idle deadline
from connection start and it fires at half the handshake deadline. The old
check therefore never matched the case it was written for, so a fully blocked
UDP path scored nothing. Connection lifetime moves to the wall clock, because
the monotonic clock pauses while the machine sleeps and made a slept-through
connection look seconds old - the exact false positive the age threshold
exists to prevent. A connection that carried bytes now counts as healthy:
the pool closes idle connections after about a minute, so short-RPC peers
never reached the age threshold and their strikes could only ever climb. A
stateless reset is evidence the path works, not against it.

Policy. Strikes decay after an hour and peers that can no longer affect a
decision are pruned, so the state tracks recent trouble instead of growing
for the life of the install. A demoted peer is dialed quic-first once every
ten minutes: dialing stops at the first working scheme, so without a probe a
demotion suppressed the only evidence that could lift it. Demotion is
suspended while yamux itself is failing, and a dial-time strike requires that
another scheme carried the same dial - otherwise an ordinary outage would
demote every peer, and on a censored network the adversary would get to
choose our transport. TTLs are jittered, since a deterministic ladder leaks
how many episodes an install has seen and lines devices up.

Also: the network-wide verdict is cached instead of rescanned under the lock
on every dial, the conn observer is stored atomically and watch is a no-op
without one (a server node has none, and a nil call in a detached goroutine
would take the process down), and snapshots carry a schema version so a shape
change here cannot be misread by a client that persisted an older one.
scheme("") returns yamux, so deriving the winning scheme from the connected
address - an obvious-looking tidy-up of the dial loop - silently reports a
yamux success for a dial where nothing connected. That would clear the
fallback-failing signal and let an ordinary outage strike every peer, which
is the false positive the working-fallback rule exists to prevent. Verified
the test fails against that refactor.
The suspend-while-yamux-fails rule was a global one-way latch. Any failed
yamux dial set it - including the sleeping LAN peers heart re-dials every
twenty seconds - and only a dial that finished on yamux could clear it. In
the case this feature exists for the quic dial keeps succeeding, so yamux is
never dialed again and nothing ever clears it: one offline blip early in a
session silently disabled demotion for the rest of the process while strikes
piled up unused. reset() did not clear it either, so a network change did not
recover it.

Date the verdict instead of latching it, expire it after five minutes since
it is evidence about the network we are on right now, count only network
nodes for the same reason they alone drive the network-wide demotion, and
clear it - with the global probe clock - on reset.
…port

Two ways a dial could teach the wrong lesson.

A connection that is opened and then rejected - a stale address that now
points at a different peer - was reported before the peer id was checked, so
it counted as the fallback working and, if a quic address had timed out
earlier in the same loop, as a strike against a peer we demonstrably never
reached. Report once the dial is fully resolved instead.

And any non-quic scheme counted as proof that the path works without udp,
but webtransport and iroh are quic-based themselves: succeeding on one of
them means udp works, the opposite of what a strike records. Only yamux
qualifies.
The lifetime shortcut ran before the cause check, so a connection that lived
past five minutes and then died of an idle timeout was scored healthy - which
deletes the peer's entry outright, backoff included. An idle timeout always
means the path went black, since keepalives are on and a working path never
reaches one; when it happens late it is usually sleep or a network change,
which is no evidence either way and must not wipe a demotion.

Also covers three behaviours mutation testing found undefended: seed must
restore the network-wide verdict (the reason persistence exists at all),
reset must clear it, and a failing quic dial must not be read as the yamux
fallback failing. Each new test was checked against its mutant.
…cuments

globalDemotionMinPeers appeared only in comments - the rule was hard-coded as
a first/second-latest scan, so changing the constant changed nothing. Take
the Nth-latest deadline instead, which is both what the constant says and
what the rule means: the moment fewer than N nodes are still demoted.

Arming the global probe now also requires a demotion that is actually in
effect; an expired entry that prune deliberately keeps could previously push
the deadline forward and arm the clock with nothing demoted.

Drops the enabled flag left over from the pre-component design, an
IsDialDegraded re-export with no callers anywhere, and an orphaned doc
comment from the package move; and says plainly that quicDemoted is the state
query while dialing goes through demoteDial.
@requilence
requilence requested a review from cheggaaa September 1, 2026 16:36
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

New Coverage 61.8% of statements
Patch Coverage 99.0% of changed statements (204/206)

Coverage provided by https://github.com/seriousben/go-patch-cover-action

penalty.go moved to net/quicdemotion but penalty_test.go stayed behind, so
its name pointed at a file no longer in the package. What it actually covers
is the seam between dialing and the demotion component.
The peerservice-side tests were reaching through Dial into the demotion
component - seeding a snapshot, then asserting on Snapshot() - so they
restated policy that net/quicdemotion already covers and made the wrong
package responsible for it.

Assert what this layer actually decides instead: the DialOutcome it reports
and whether it reorders the schemes, against a stub component. Policy stays
where it is implemented. The file is named for the peerservice concept it
covers, since penalty.go and quicdemotion.go both live elsewhere.
@requilence
requilence merged commit 1b94c20 into main Sep 1, 2026
4 checks passed
@requilence
requilence deleted the go-7467-quic-degradation-fallback-2 branch September 1, 2026 16:47
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants