Skip to content

Commit 0490606

Browse files
committed
fix(mesh-conn): converge ICE auth-race with asymmetric retry + supersede grace
The bug: on near-simultaneous restart (the normal startup case) mesh-conn's pollLoop superseded each peer's in-flight handshake the moment fresher peer auth arrived; each abort's retry rebuilt the ice.Agent (mandatory — pion's Restart() doesn't support re-Dial; see peerSession docstring), republished a fresh local auth, and triggered the peer's symmetric supersession. Both sides flapped indefinitely. Observed live on 2026-05-13 as multiple peer pairs stuck in the "fresh auth supersedes consumed → ice: connecting canceled by caller → retrying in 5s → Closed" loop for 7+ minutes; replica pg_basebackup never started. Two coupled changes: 1. Asymmetric retry back-off in runPeerLink, keyed on peer-id lex order: lex-smaller side waits 2 s, larger waits 5 s. The 3 s gap guarantees one side's retry is the authoritative auth publisher per cycle; by the time the slower side wakes up, the faster side is in "wait for auth" (consumedAuth=zero) and the supersession check short-circuits. 2. A 3 s grace window on the supersession check in pollLoop: an in-flight handshake is only allowed to be aborted by a fresh peer auth if it has been running long enough that the auth credibly signals a peer-side credential roll rather than early-race noise. On loopback host candidates the legitimate handshake completes in ~60 ms — well inside the grace — so any forge / re-emit landing in that window is ignored. Either fix alone is insufficient — measured against the regression test, asymmetric back-off alone keeps flapping (the supersede still fires on the in-flight peer mid-Checking before its retry even sleeps), and a grace window alone leaves a race where the symmetric retry republishes auth in close-enough succession that the broken fallback path can still re-enter. Composed, the AFTER measurement is 50/50 passes in 3.5 s total. Regression test (mesh-conn/main_test.go::TestAuthRaceConvergence): - in-process harness with two Mesh instances + an inlined /publish + /poll broker (matches signaling/main.go's wire format), - forges fresh peer auth in a 20-shot burst gated on a new onAttemptStarted Mesh hook (fires the instant consumedAuth gets set inside dialICE) so the forge reliably lands in the in-flight window the supersede check guards, - asserts both peers reach "link up" within 30 s. Numbers: BEFORE the fix the regression test fails 32 / 1-pass / 33 trials with the original single-shot forge variant (the 1 pass was the link-came-up-before-forge race that motivated the new attemptStarted hook); on the sustained-forge + hook variant the BEFORE failure rate is 100 %. AFTER the fix: 50 / 50 passes in 3.5 s total. Refactor of pollLoop / runPeerLink / dialAndPump / dialICE into *Mesh methods (instance-scoped sessions, ctx-bounded shutdown, onLinkUp + onAttemptStarted hooks) landed in the prior commit; this commit is the actual fix + the strengthened test.
1 parent fa274ef commit 0490606

3 files changed

Lines changed: 144 additions & 57 deletions

File tree

consul-postgres-ha/mesh-conn/harness_test.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,13 @@ func (b *testBroker) handlePoll(w http.ResponseWriter, r *http.Request) {
116116
// =============================================================================
117117

118118
type testPeer struct {
119-
id string
120-
mesh *Mesh
121-
cancel context.CancelFunc
122-
linkUp chan string // peer-ID of the OTHER side, sent once per "link up"
123-
done chan struct{}
124-
logPrefix string
119+
id string
120+
mesh *Mesh
121+
cancel context.CancelFunc
122+
linkUp chan string // peer-ID of the OTHER side, sent once per "link up"
123+
attemptStarted chan string // peer-ID of the OTHER side, sent every time consumedAuth gets set
124+
done chan struct{}
125+
logPrefix string
125126
}
126127

127128
// newTestPeer builds a Mesh for one side. peers is the full PEERS_JSON
@@ -145,18 +146,25 @@ func newTestPeer(t *testing.T, brokerURL, selfID string, peers []Peer, allowlist
145146
}
146147
m := newMesh(cfg)
147148
tp := &testPeer{
148-
id: selfID,
149-
mesh: m,
150-
linkUp: make(chan string, 64),
151-
done: make(chan struct{}),
152-
logPrefix: "[" + selfID + "] ",
149+
id: selfID,
150+
mesh: m,
151+
linkUp: make(chan string, 64),
152+
attemptStarted: make(chan string, 64),
153+
done: make(chan struct{}),
154+
logPrefix: "[" + selfID + "] ",
153155
}
154156
m.onLinkUp = func(peerID string) {
155157
select {
156158
case tp.linkUp <- peerID:
157159
default:
158160
}
159161
}
162+
m.onAttemptStarted = func(peerID string) {
163+
select {
164+
case tp.attemptStarted <- peerID:
165+
default:
166+
}
167+
}
160168
return tp
161169
}
162170

consul-postgres-ha/mesh-conn/main.go

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,14 @@ type Mesh struct {
397397
// onLinkUp is an optional test hook fired right after each
398398
// per-peer "link up" log line. nil in production.
399399
onLinkUp func(peerID string)
400+
401+
// onAttemptStarted is an optional test hook fired when a per-peer
402+
// dialICE attempt has consumed the remote auth and is about to
403+
// call agent.Dial/Accept. Used by the auth-race test to
404+
// synchronise the race-trigger injection with the precise moment
405+
// the bug requires (consumedAuth set, dial in flight). nil in
406+
// production.
407+
onAttemptStarted func(peerID string)
400408
}
401409

402410
func newMesh(cfg *Config) *Mesh {
@@ -446,9 +454,10 @@ func (m *Mesh) runPeerLink(ctx context.Context, peer Peer) {
446454
if ctx.Err() != nil {
447455
return
448456
}
457+
backoff := retryBackoff(m.cfg.SelfID, peer.ID)
449458
if err := m.dialAndPump(ctx, peer, sess); err != nil {
450-
log.Printf("[%s] link failed: %v — retrying in 5s", peer.ID, err)
451-
if !sleepCtx(ctx, 5*time.Second) {
459+
log.Printf("[%s] link failed: %v — retrying in %s", peer.ID, err, backoff)
460+
if !sleepCtx(ctx, backoff) {
452461
return
453462
}
454463
continue
@@ -458,6 +467,39 @@ func (m *Mesh) runPeerLink(ctx context.Context, peer Peer) {
458467
}
459468
}
460469

470+
// retryBackoff is the per-peer wait between failed dialAndPump
471+
// attempts. Asymmetric on peer-id lex order: the side with the
472+
// lexicographically SMALLER id retries after 2 s, the larger-id side
473+
// after 5 s. The asymmetry exists to break the convergence bug where
474+
// two peers indefinitely supersede each other's in-flight handshakes
475+
// on near-simultaneous restart — see ROBUSTNESS.md "ICE auth-race
476+
// convergence" for the failure analysis.
477+
//
478+
// Mechanism: with a fixed retry both peers republish auth at the
479+
// same cadence, so each new publish lands in the OTHER side's
480+
// mid-Checking window (consumedAuth = old peer auth, fresh auth
481+
// arriving from peer → supersede → abort → retry → publish → repeat).
482+
// With an asymmetric retry, the lower-id side republishes ~3 s before
483+
// the higher-id side wakes up. By the time the higher-id side
484+
// publishes its own auth, the lower side is in the "wait for auth"
485+
// phase of its next attempt (consumedAuth = zero) and the supersede
486+
// check `consumedAuth != zero` short-circuits. The new auth is
487+
// pushed into authCh; both sides consume each other's fresh creds
488+
// and the next dial converges.
489+
//
490+
// No jitter: determinism matters for the regression test, and the
491+
// lex split alone is sufficient on every observed repro path.
492+
// The pair-of-numbers (2 s, 5 s) is chosen so the gap (3 s) comfortably
493+
// straddles pion's slow-path abort window — observed at up to ~1 s
494+
// from `OnConnectionStateChange Failed` to `dialAndPump` returning
495+
// — without making the lower side hot-spin.
496+
func retryBackoff(selfID, peerID string) time.Duration {
497+
if selfID < peerID {
498+
return 2 * time.Second
499+
}
500+
return 5 * time.Second
501+
}
502+
461503
// sleepCtx sleeps for d or until ctx is cancelled. Returns true if
462504
// the full sleep completed (so the caller should continue), false if
463505
// the caller should bail.
@@ -511,6 +553,7 @@ func (m *Mesh) dialAndPump(parentCtx context.Context, peer Peer, sess *peerSessi
511553
attemptCtx, abort := context.WithCancel(parentCtx)
512554
sess.mu.Lock()
513555
sess.consumedAuth = [2]string{}
556+
sess.consumedAt = time.Time{}
514557
sess.abortAttempt = abort
515558
sess.mu.Unlock()
516559
defer func() {
@@ -1012,10 +1055,30 @@ type peerSession struct {
10121055
mu sync.Mutex
10131056
agent *ice.Agent // replaced on every dialICE attempt; guarded by mu
10141057
consumedAuth [2]string // ufrag,pwd dialICE pulled off authCh; zero before
1058+
consumedAt time.Time // when consumedAuth was last set; zero before
10151059
abortAttempt context.CancelFunc // non-nil for the duration of an attempt
10161060
candidateBuffer []ice.Candidate // remote candidates seen since last peer-auth; replayed into each new agent
10171061
}
10181062

1063+
// supersedeGrace is the minimum time an attempt must have been
1064+
// running with consumedAuth set before pollLoop will supersede it on
1065+
// a fresh peer auth. Inside this window we trust the in-flight ICE
1066+
// handshake to either converge or fail on its own (host loopback
1067+
// converges in ~50 ms; over WAN+TURN it can take a couple of seconds
1068+
// of connectivity checks before settling). Outside the window, fresh
1069+
// auth from the peer is treated as a credible restart signal and is
1070+
// allowed to abort the attempt.
1071+
//
1072+
// Why this is necessary: without a grace window, every fresh-auth
1073+
// arrival aborts the in-flight dial — including auths that arrived
1074+
// for entirely innocuous reasons (peer's pollLoop happened to
1075+
// re-emit, or a delayed broker delivery). After abort, runPeerLink
1076+
// retries; the retry republishes auth; the peer side sees fresh auth
1077+
// and supersedes its own in-flight dial; and the symmetry never
1078+
// breaks. The grace window cuts this loop by absorbing the early-
1079+
// race noise.
1080+
const supersedeGrace = 3 * time.Second
1081+
10191082
// currentSession returns the persistent session for remoteID, or nil
10201083
// if setupPeerSession hasn't installed it yet. Used by pollLoop to
10211084
// dispatch incoming messages to the right agent.
@@ -1164,10 +1227,15 @@ func (m *Mesh) dialICE(remoteID string, sess *peerSession, attemptCtx context.Co
11641227

11651228
// Record what we're about to dial against, so pollLoop can detect
11661229
// fresher auth from the peer and abort the attempt. See peerSession
1167-
// docstring for why this is necessary.
1230+
// docstring for why this is necessary. consumedAt anchors the
1231+
// supersede grace window (see supersedeGrace).
11681232
sess.mu.Lock()
11691233
sess.consumedAuth = remote
1234+
sess.consumedAt = time.Now()
11701235
sess.mu.Unlock()
1236+
if m.onAttemptStarted != nil {
1237+
m.onAttemptStarted(remoteID)
1238+
}
11711239

11721240
// 60s safety net; pion's default connectivity-check window is 30s
11731241
// so if Dial/Accept hasn't succeeded by then the state callback
@@ -1292,8 +1360,11 @@ func (m *Mesh) pollLoop(ctx context.Context) {
12921360
// belong to the previous peer epoch and won't
12931361
// pair against the new credentials.
12941362
// 2. If an attempt is currently in flight against
1295-
// different consumed creds, abort it; runPeerLink
1296-
// retries with a fresh agent + republished auth.
1363+
// different consumed creds AND has been running
1364+
// long enough (see supersedeGrace) for the
1365+
// handshake to have had a fair chance, abort it;
1366+
// runPeerLink retries with a fresh agent +
1367+
// republished auth.
12971368
// 3. (Outside mu, below.) Replace the buffered auth
12981369
// in authCh so the next dialICE iteration reads
12991370
// the freshest value.
@@ -1303,9 +1374,15 @@ func (m *Mesh) pollLoop(ctx context.Context) {
13031374
if sess.abortAttempt != nil &&
13041375
sess.consumedAuth != zero &&
13051376
sess.consumedAuth != newAuth {
1306-
log.Printf("[%s] fresh auth (ufrag=%s) supersedes consumed (ufrag=%s) — aborting attempt",
1307-
msg.From, newAuth[0], sess.consumedAuth[0])
1308-
sess.abortAttempt()
1377+
elapsed := time.Since(sess.consumedAt)
1378+
if elapsed >= supersedeGrace {
1379+
log.Printf("[%s] fresh auth (ufrag=%s) supersedes consumed (ufrag=%s) after %s — aborting attempt",
1380+
msg.From, newAuth[0], sess.consumedAuth[0], elapsed.Round(time.Millisecond))
1381+
sess.abortAttempt()
1382+
} else {
1383+
log.Printf("[%s] fresh auth (ufrag=%s) ignored — in-flight attempt has only run %s (< %s grace); newer creds queued for the next attempt",
1384+
msg.From, newAuth[0], elapsed.Round(time.Millisecond), supersedeGrace)
1385+
}
13091386
}
13101387
sess.mu.Unlock()
13111388

consul-postgres-ha/mesh-conn/main_test.go

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package main
33
import (
44
"context"
55
"fmt"
6-
"strings"
76
"testing"
87
"time"
98
)
@@ -48,26 +47,6 @@ func (b *testBroker) publishAuth(from, to, ufrag, pwd string) {
4847
b.push(to, Message{From: from, Type: "auth", Data: ufrag + ":" + pwd})
4948
}
5049

51-
// waitBothPublishedAuth waits until both peers have observed each
52-
// other's auth (visible in the log as "ice state: Checking" or similar
53-
// progress). On a green path, this is the point at which an injected
54-
// fresh-auth will actually supersede a consumed value.
55-
//
56-
// We use a log-grep ("ice state:") because it's the cheapest progress
57-
// signal that mesh-conn already emits per peer.
58-
func waitForChecking(t *testing.T, sink *testLogSink, peerID string, deadline time.Duration) error {
59-
t.Helper()
60-
needle := fmt.Sprintf("[%s] ice state: Checking", peerID)
61-
endByT := time.Now().Add(deadline)
62-
for time.Now().Before(endByT) {
63-
if strings.Contains(sink.String(), needle) {
64-
return nil
65-
}
66-
time.Sleep(20 * time.Millisecond)
67-
}
68-
return fmt.Errorf("timed out waiting for %q in logs", needle)
69-
}
70-
7150
// TestAuthRaceConvergence is the regression test for the ICE auth-race
7251
// convergence bug.
7352
//
@@ -104,25 +83,48 @@ func TestAuthRaceConvergence(t *testing.T) {
10483
a.start(ctx)
10584
b.start(ctx)
10685

107-
// Wait until both peers have reached the connectivity-check phase —
108-
// this is the moment where sess.consumedAuth is non-zero on both
109-
// sides and a fresh-auth injection will trigger the supersession
110-
// path. Without this wait, the injection might race ahead of the
111-
// auth consumption and just get dropped.
112-
if err := waitForChecking(t, sink, "peer-B", 5*time.Second); err != nil {
113-
// Peer-A's log line names the OTHER side ("peer-B"), so we
114-
// wait for "[peer-B] ice state: Checking" which is logged from
115-
// inside peer-A's Mesh.
116-
t.Fatalf("peer-A never reached Checking: %v\n\nlogs:\n%s", err, sink.String())
117-
}
118-
if err := waitForChecking(t, sink, "peer-A", 5*time.Second); err != nil {
119-
t.Fatalf("peer-B never reached Checking: %v\n\nlogs:\n%s", err, sink.String())
86+
// Wait until peer-A has actually consumed peer-B's real auth and
87+
// is about to call agent.Dial. The onAttemptStarted hook fires at
88+
// exactly that moment inside dialICE (after `sess.consumedAuth =
89+
// remote`, before `agent.Dial`/`Accept`). Injecting the forge
90+
// before this point would just race the broker queue and possibly
91+
// be the value peer-A consumes from authCh — that would dial
92+
// against fake creds and never reach Checking. Injecting after
93+
// this point guarantees we land inside the in-flight window the
94+
// supersession check guards.
95+
select {
96+
case <-a.attemptStarted:
97+
case <-time.After(5 * time.Second):
98+
t.Fatalf("peer-A never consumed remote auth\n\nlogs:\n%s", sink.String())
12099
}
121100

122-
// Inject a forged fresh-auth from peer-B to peer-A. This impersonates
123-
// "peer-B restarted while peer-A was mid-Dial" and triggers the
124-
// supersession path in peer-A's pollLoop.
125-
broker.publishAuth("peer-B", "peer-A", "fakeufragXYZ123", "fakepwdABCDEF0123456789012")
101+
// Sustained forge injection: a single forge can land after the
102+
// loopback ICE check completes (~10–60 ms window) and slip a
103+
// "passed" result through without exercising the bug. A burst of
104+
// 20 forges at 50 ms intervals plugs that window without
105+
// poisoning the original authCh (peer-A has already consumed
106+
// peer-B's real auth before we start). On the BROKEN code the
107+
// first forge that arrives while peer-A is still in connectivity-
108+
// check triggers the supersession path; from there the symmetric
109+
// retry-and-republish loop never converges. On the FIXED code the
110+
// grace window absorbs every forge and the legitimate ICE check
111+
// completes.
112+
forgeCtx, stopForge := context.WithCancel(ctx)
113+
t.Cleanup(stopForge)
114+
go func() {
115+
ticker := time.NewTicker(50 * time.Millisecond)
116+
defer ticker.Stop()
117+
for i := 0; i < 20; i++ {
118+
ufrag := fmt.Sprintf("forge%04dABCDEFGH", i)
119+
pwd := fmt.Sprintf("forgepwd%04dABCDEFGHIJKLMNOPQRSTU", i)
120+
broker.publishAuth("peer-B", "peer-A", ufrag, pwd)
121+
select {
122+
case <-forgeCtx.Done():
123+
return
124+
case <-ticker.C:
125+
}
126+
}
127+
}()
126128

127129
// Now assert that despite the race kick, both peers eventually
128130
// converge to link-up within 30 s. Before the fix this fails the

0 commit comments

Comments
 (0)