lx: early rebind and wake nudge for provably dead sessions (sing-box-lx SPEC 041 v2)
The v1 give-up rebind heals a dead 5-tuple only at ~90s, while users probe within the first seconds after device wake. Two new triggers over the same action and shared debounce window: - early: >=3 unanswered initiations against a provably dead session (no live keypair, or last handshake older than RejectAfterTime) rebind at ~15s from the retry branch; a live session keeps byte-for-byte upstream behaviour; - nudge: public Device.RebindIfSessionStale() lets the consumer report a device wake-up and heal stale peers immediately, without traffic demand. The whole mechanism now lives in device/lx_giveup_rebind.go (moved from device.go to keep the upstream file delta minimal); the rebind log line carries the trigger label.
This commit is contained in:
parent
c4e0bcf768
commit
3909adbf87
5 changed files with 444 additions and 60 deletions
|
|
@ -123,17 +123,14 @@ type Device struct {
|
|||
|
||||
ipackets [5]*obfChain
|
||||
|
||||
// lx: SPEC 041 — passive self-heal on handshake give-up. When a peer's
|
||||
// handshake retry cycle exhausts (the give-up branch of
|
||||
// expiredRetransmitHandshake), the device reopens its bind once — with a
|
||||
// fresh ephemeral port when freshPort is set — and immediately
|
||||
// re-initiates. Heals dead per-flow path state (an expired NAT mapping or
|
||||
// a poisoned DPI flow entry) that otherwise pins every retry to the same
|
||||
// dead 5-tuple until a manual reconnect. Zero cost while healthy: no
|
||||
// timers, no goroutines — the trigger is the existing give-up event,
|
||||
// which only fires under traffic demand after ~90s of unanswered
|
||||
// initiations. Enabled by default; sing-box decides freshPort from
|
||||
// whether the user pinned listen_port.
|
||||
// lx: SPEC 041 — passive self-heal state: reopen the bind once (fresh
|
||||
// ephemeral port when freshPort is set) and immediately re-initiate, to
|
||||
// heal dead per-flow path state (an expired NAT mapping or a poisoned DPI
|
||||
// flow entry) that otherwise pins every retry to the same dead 5-tuple
|
||||
// until a manual reconnect. The whole mechanism — three triggers (giveup /
|
||||
// early / nudge) sharing this state and its debounce — lives in
|
||||
// lx_giveup_rebind.go. Enabled by default; sing-box decides freshPort
|
||||
// from whether the user pinned listen_port.
|
||||
giveUpRebind struct {
|
||||
enabled atomic.Bool
|
||||
freshPort atomic.Bool
|
||||
|
|
@ -803,55 +800,6 @@ func (device *Device) BindUpdate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// lx: SPEC 041 — configure the handshake give-up self-heal (see the
|
||||
// giveUpRebind field comment). freshPort must be false when the user pinned
|
||||
// an explicit listen_port: the pinned port is preserved, at the cost of the
|
||||
// rebind not changing the 5-tuple.
|
||||
func (device *Device) SetGiveUpRebind(enabled, freshPort bool) {
|
||||
device.giveUpRebind.enabled.Store(enabled)
|
||||
device.giveUpRebind.freshPort.Store(freshPort)
|
||||
}
|
||||
|
||||
// lx: SPEC 041 — invoked from the give-up branch of
|
||||
// expiredRetransmitHandshake: ~90s of initiations went unanswered, so the
|
||||
// current socket's 5-tuple is proven dead. Reopen the bind (fresh ephemeral
|
||||
// port when allowed) and kick a new handshake cycle immediately. Runs the
|
||||
// heavy part in a goroutine so the timer callback never blocks on
|
||||
// BindUpdate's worker drain. Debounced to one rebind per RekeyAttemptTime
|
||||
// per device (CAS on `last` settles concurrent multi-peer give-ups). On a
|
||||
// down or closed device BindUpdate does not reopen the socket, so a rebind
|
||||
// racing idle-suspend (SPEC 020) or Close degrades to a no-op.
|
||||
func (device *Device) handleHandshakeGiveUp(peer *Peer) {
|
||||
if !device.giveUpRebind.enabled.Load() {
|
||||
return
|
||||
}
|
||||
if device.isClosed() {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
last := device.giveUpRebind.last.Load()
|
||||
if now-last < int64(RekeyAttemptTime/time.Second) {
|
||||
return
|
||||
}
|
||||
if !device.giveUpRebind.last.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
fresh := device.giveUpRebind.freshPort.Load()
|
||||
go func() {
|
||||
if fresh {
|
||||
device.net.Lock()
|
||||
device.net.port = 0
|
||||
device.net.Unlock()
|
||||
}
|
||||
if err := device.BindUpdate(); err != nil {
|
||||
device.log.Errorf("%v - Failed to rebind after handshake give-up: %v", peer, err)
|
||||
return
|
||||
}
|
||||
device.log.Verbosef("%v - Rebound socket after handshake give-up (fresh port=%v)", peer, fresh)
|
||||
peer.SendHandshakeInitiation(false)
|
||||
}()
|
||||
}
|
||||
|
||||
func (device *Device) BindClose() error {
|
||||
device.net.Lock()
|
||||
err := closeBindLocked(device)
|
||||
|
|
|
|||
58
device/lx_early_rebind_test.go
Normal file
58
device/lx_early_rebind_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* lx: SPEC 041 v2 — behavioural red/green test for the EARLY give-up rebind.
|
||||
* Field failure mode (the v1 field leftover, dump 2026-08-01): the v1 give-up
|
||||
* rebind heals a dead 5-tuple, but only at ~90s after the first demand — while
|
||||
* the user pings within the first 5-35s after device wake and sees every node
|
||||
* in ERR. The early trigger fires from the retry branch once >=3 initiations
|
||||
* went unanswered AND the session is provably dead (no live keypair, or last
|
||||
* handshake older than RejectAfterTime), shrinking the ERR window to ~15-20s.
|
||||
*
|
||||
* The test reuses the v1 harness (gateBind: first socket generation blackholes
|
||||
* every send) and drives the peer to the 3rd retry expiry — the state a real
|
||||
* ~15s of unanswered retries ends in. Post-fix the retry branch rebinds and
|
||||
* the tunnel comes up; pre-fix (v1 base) the retry keeps dying in the first
|
||||
* socket generation and only the ~90s give-up would heal, so the packet never
|
||||
* arrives within the test window.
|
||||
*
|
||||
* This file deliberately uses NO post-fix API, so it compiles and runs RED on
|
||||
* the v1 base commit.
|
||||
*/
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEarlyRebindSelfHeal: dead first socket, cold session (no keypair yet),
|
||||
// 3rd retry expiry fires — the tunnel must come up and deliver traffic without
|
||||
// waiting out the full 90s give-up cycle.
|
||||
func TestEarlyRebindSelfHeal(t *testing.T) {
|
||||
pair := newGiveUpPair(t, true)
|
||||
|
||||
pkt := buildIPv4Packet(testIPA, testIPB, 8)
|
||||
send := func() { pair.tunA.toDevice <- pkt }
|
||||
|
||||
// Traffic demand: stages the packet and sends the first (blackholed)
|
||||
// initiation.
|
||||
send()
|
||||
|
||||
pair.devA.peers.RLock()
|
||||
peer := pair.devA.peers.keyMap[pair.pkB]
|
||||
pair.devA.peers.RUnlock()
|
||||
if peer == nil {
|
||||
t.Fatal("peer not found")
|
||||
}
|
||||
|
||||
// Simulate reaching the 3rd retry expiry (~15s in the field): two retries
|
||||
// already counted, the last initiation older than the retransmit timeout.
|
||||
peer.handshake.mutex.Lock()
|
||||
peer.handshake.lastSentHandshake = time.Now().Add(-2 * RekeyTimeout)
|
||||
peer.handshake.mutex.Unlock()
|
||||
peer.timers.handshakeAttempts.Store(2)
|
||||
expiredRetransmitHandshake(peer)
|
||||
|
||||
awaitPacket(t, pair.tunB, pkt, send)
|
||||
}
|
||||
143
device/lx_giveup_rebind.go
Normal file
143
device/lx_giveup_rebind.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* lx: SPEC 041 — passive self-heal for a dead per-flow path (an expired NAT
|
||||
* mapping or a poisoned DPI flow entry that pins every retry to the same dead
|
||||
* 5-tuple until a manual reconnect). One mechanism — reopen the bind (fresh
|
||||
* ephemeral port when allowed) and immediately re-initiate — with three
|
||||
* triggers sharing one debounce window:
|
||||
*
|
||||
* giveup — the handshake retry cycle exhausted (~90s of unanswered
|
||||
* initiations under traffic demand); safety net, covers every path;
|
||||
* early — >=3 unanswered initiations against a provably dead session
|
||||
* (see sessionProvablyDead): no point waiting out the rest of the
|
||||
* cycle, rebind at ~15s instead of ~90s;
|
||||
* nudge — the consumer reports "device woke up" via
|
||||
* Device.RebindIfSessionStale (wired through sing-box libbox);
|
||||
* heals without waiting for traffic demand at all.
|
||||
*
|
||||
* Zero cost while healthy: no timers, no goroutines — triggers 1-2 live in
|
||||
* the existing retry cycle, trigger 3 is paid by the caller. The state lives
|
||||
* in Device.giveUpRebind (device.go); enabled defaults to true in NewDevice,
|
||||
* sing-box decides freshPort from whether the user pinned listen_port.
|
||||
*/
|
||||
|
||||
package device
|
||||
|
||||
import "time"
|
||||
|
||||
// earlyGiveUpMinAttempts is the number of unanswered initiations (retry timer
|
||||
// expiries) after which a provably dead session is rebound early instead of
|
||||
// waiting out the full RekeyAttemptTime cycle: ~15s at RekeyTimeout=5s.
|
||||
const earlyGiveUpMinAttempts = 3
|
||||
|
||||
// SetGiveUpRebind configures the self-heal (see the giveUpRebind field
|
||||
// comment in device.go). freshPort must be false when the user pinned an
|
||||
// explicit listen_port: the pinned port is preserved, at the cost of the
|
||||
// rebind not changing the 5-tuple.
|
||||
func (device *Device) SetGiveUpRebind(enabled, freshPort bool) {
|
||||
device.giveUpRebind.enabled.Store(enabled)
|
||||
device.giveUpRebind.freshPort.Store(freshPort)
|
||||
}
|
||||
|
||||
// handleHandshakeGiveUp is invoked from the give-up branch of
|
||||
// expiredRetransmitHandshake: ~90s of initiations went unanswered, so the
|
||||
// current socket's 5-tuple is proven dead.
|
||||
func (device *Device) handleHandshakeGiveUp(peer *Peer) {
|
||||
device.selfHealRebind("giveup", peer)
|
||||
}
|
||||
|
||||
// maybeEarlyGiveUpRebind is invoked from the RETRY branch of
|
||||
// expiredRetransmitHandshake. Once enough initiations went unanswered AND the
|
||||
// session is provably dead there is nothing left to protect — rebind now, at
|
||||
// ~15s instead of ~90s. The retry cycle itself continues untouched: this only
|
||||
// moves the socket under it. A live session with transient packet loss fails
|
||||
// sessionProvablyDead and keeps byte-for-byte upstream behaviour; the shared
|
||||
// debounce means this also suppresses the giveup rebind of the same series.
|
||||
func (device *Device) maybeEarlyGiveUpRebind(peer *Peer) {
|
||||
if peer.timers.handshakeAttempts.Load() < earlyGiveUpMinAttempts {
|
||||
return
|
||||
}
|
||||
if !device.sessionProvablyDead(peer) {
|
||||
return
|
||||
}
|
||||
device.selfHealRebind("early", peer)
|
||||
}
|
||||
|
||||
// sessionProvablyDead reports whether the peer's session is beyond saving: no
|
||||
// live keypair, or the last successful handshake is older than
|
||||
// RejectAfterTime (the keys are invalid after that, so a rebind loses
|
||||
// nothing). The stale predicate shared by the early and nudge triggers.
|
||||
func (device *Device) sessionProvablyDead(peer *Peer) bool {
|
||||
if peer.keypairs.Current() == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(time.Unix(0, peer.lastHandshakeNano.Load())) > RejectAfterTime
|
||||
}
|
||||
|
||||
// RebindIfSessionStale is the wake-nudge entry (trigger 3): the consumer
|
||||
// observed a device wake-up and asks for an immediate heal instead of waiting
|
||||
// for traffic demand to walk the retry cycle. If any running peer's session
|
||||
// is provably dead the bind is reopened once (shared debounce) and every such
|
||||
// peer re-initiates immediately; a healthy device is a no-op. Returns whether
|
||||
// a rebind was actually scheduled. Never blocks on the rebind itself — the
|
||||
// heavy part runs in a goroutine (see selfHealRebind). On a down or closed
|
||||
// device it is a no-op, so callers racing idle-suspend or Close are safe.
|
||||
func (device *Device) RebindIfSessionStale() bool {
|
||||
if !device.giveUpRebind.enabled.Load() || !device.isUp() {
|
||||
return false
|
||||
}
|
||||
var stale []*Peer
|
||||
device.peers.RLock()
|
||||
for _, peer := range device.peers.keyMap {
|
||||
if peer.isRunning.Load() && device.sessionProvablyDead(peer) {
|
||||
stale = append(stale, peer)
|
||||
}
|
||||
}
|
||||
device.peers.RUnlock()
|
||||
if len(stale) == 0 {
|
||||
return false
|
||||
}
|
||||
return device.selfHealRebind("nudge", stale...)
|
||||
}
|
||||
|
||||
// selfHealRebind is the shared action behind all three triggers. Runs the
|
||||
// heavy part in a goroutine so a timer callback (or a nudge caller) never
|
||||
// blocks on BindUpdate's worker drain. Debounced to one rebind per
|
||||
// RekeyAttemptTime per device across ALL triggers (CAS on `last` settles
|
||||
// concurrent multi-peer races): an early rebind at ~15s suppresses the giveup
|
||||
// rebind of the same failed series at ~90s. On a down or closed device
|
||||
// BindUpdate does not reopen the socket, so a rebind racing idle-suspend
|
||||
// (SPEC 020) or Close degrades to a no-op.
|
||||
func (device *Device) selfHealRebind(trigger string, peers ...*Peer) bool {
|
||||
if !device.giveUpRebind.enabled.Load() {
|
||||
return false
|
||||
}
|
||||
if device.isClosed() {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
last := device.giveUpRebind.last.Load()
|
||||
if now-last < int64(RekeyAttemptTime/time.Second) {
|
||||
return false
|
||||
}
|
||||
if !device.giveUpRebind.last.CompareAndSwap(last, now) {
|
||||
return false
|
||||
}
|
||||
fresh := device.giveUpRebind.freshPort.Load()
|
||||
go func() {
|
||||
if fresh {
|
||||
device.net.Lock()
|
||||
device.net.port = 0
|
||||
device.net.Unlock()
|
||||
}
|
||||
if err := device.BindUpdate(); err != nil {
|
||||
device.log.Errorf("%v - Failed self-heal rebind (trigger=%s): %v", peers[0], trigger, err)
|
||||
return
|
||||
}
|
||||
device.log.Verbosef("%v - Rebound socket for self-heal (trigger=%s, fresh port=%v)", peers[0], trigger, fresh)
|
||||
for _, peer := range peers {
|
||||
peer.SendHandshakeInitiation(false)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
228
device/lx_stale_rebind_test.go
Normal file
228
device/lx_stale_rebind_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* lx: SPEC 041 v2 — unit tests for the stale predicate, the wake nudge
|
||||
* (RebindIfSessionStale) and the shared debounce across triggers, on top of
|
||||
* the v1 harness (lx_giveup_selfheal_test.go). These use the post-fix API and
|
||||
* are NOT expected to compile on the pre-fix base.
|
||||
*/
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// establishTunnel completes a real handshake over a healthy pair so the peer
|
||||
// holds a live keypair and a fresh lastHandshakeNano.
|
||||
func establishTunnel(t *testing.T, pair *giveUpPair) {
|
||||
t.Helper()
|
||||
pkt := buildIPv4Packet(testIPA, testIPB, 8)
|
||||
send := func() { pair.tunA.toDevice <- pkt }
|
||||
send()
|
||||
awaitPacket(t, pair.tunB, pkt, send)
|
||||
}
|
||||
|
||||
func peerOf(t *testing.T, dev *Device, pk NoisePublicKey) *Peer {
|
||||
t.Helper()
|
||||
dev.peers.RLock()
|
||||
peer := dev.peers.keyMap[pk]
|
||||
dev.peers.RUnlock()
|
||||
if peer == nil {
|
||||
t.Fatal("peer not found")
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// A cold peer (no keypair yet, nothing to lose): the nudge must rebind and
|
||||
// immediately initiate — the tunnel comes up without any traffic demand.
|
||||
func TestNudgeRebindsStaleSession(t *testing.T) {
|
||||
pair := newGiveUpPair(t, true)
|
||||
|
||||
if !pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("nudge on a cold (keypair-less) session must rebind")
|
||||
}
|
||||
waitOpens(t, pair.bindA, 2)
|
||||
|
||||
// The immediate initiation must bring the tunnel up: traffic sent only
|
||||
// AFTER the nudge flows end to end.
|
||||
pkt := buildIPv4Packet(testIPA, testIPB, 8)
|
||||
send := func() { pair.tunA.toDevice <- pkt }
|
||||
send()
|
||||
awaitPacket(t, pair.tunB, pkt, send)
|
||||
}
|
||||
|
||||
// A healthy session (live keypair, fresh handshake) must be a strict no-op.
|
||||
func TestNudgeHealthySessionNoop(t *testing.T) {
|
||||
pair := newGiveUpPair(t, false)
|
||||
establishTunnel(t, pair)
|
||||
|
||||
opens := pair.bindA.openCount()
|
||||
if pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("nudge on a healthy session must not rebind")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if got := pair.bindA.openCount(); got != opens {
|
||||
t.Fatalf("healthy nudge reopened the bind: %d opens, want %d", got, opens)
|
||||
}
|
||||
}
|
||||
|
||||
// A live keypair whose last handshake is older than RejectAfterTime is
|
||||
// provably dead (the keys are invalid): the nudge must rebind.
|
||||
func TestNudgeExpiredHandshakeIsStale(t *testing.T) {
|
||||
pair := newGiveUpPair(t, false)
|
||||
establishTunnel(t, pair)
|
||||
|
||||
peer := peerOf(t, pair.devA, pair.pkB)
|
||||
peer.lastHandshakeNano.Store(time.Now().Add(-RejectAfterTime - time.Second).UnixNano())
|
||||
|
||||
if !pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("nudge on an expired session must rebind")
|
||||
}
|
||||
waitOpens(t, pair.bindA, 2)
|
||||
}
|
||||
|
||||
// A down device (how SPEC 020 idle-suspend leaves it) must be a no-op — the
|
||||
// nudge never wakes sleepers.
|
||||
func TestNudgeDownDeviceNoop(t *testing.T) {
|
||||
pair := newGiveUpPair(t, true)
|
||||
|
||||
if err := pair.devA.Down(); err != nil {
|
||||
t.Fatalf("Down: %v", err)
|
||||
}
|
||||
if pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("nudge on a down device must be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned listen_port survives the nudge rebind.
|
||||
func TestNudgePinnedPortPreserved(t *testing.T) {
|
||||
pair := newGiveUpPair(t, true)
|
||||
pair.devA.SetGiveUpRebind(true, false)
|
||||
|
||||
if !pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("nudge must rebind a cold session")
|
||||
}
|
||||
waitOpens(t, pair.bindA, 2)
|
||||
|
||||
ports := pair.bindA.portsSnapshot()
|
||||
if ports[1] != 1 {
|
||||
t.Fatalf("nudge rebind requested port %d, want 1 (pinned)", ports[1])
|
||||
}
|
||||
}
|
||||
|
||||
// The debounce window is SHARED across triggers: an early/nudge rebind
|
||||
// suppresses the give-up rebind of the same failed series, and a later series
|
||||
// (window elapsed) heals again — sliding window, not a latch.
|
||||
func TestSharedDebounceAcrossTriggers(t *testing.T) {
|
||||
pair := newGiveUpPair(t, false)
|
||||
|
||||
// First trigger of the series: nudge.
|
||||
if !pair.devA.RebindIfSessionStale() {
|
||||
t.Fatal("first nudge must rebind")
|
||||
}
|
||||
waitOpens(t, pair.bindA, 2)
|
||||
|
||||
// The give-up of the same series lands inside the window: suppressed.
|
||||
triggerGiveUp(t, pair.devA, pair.pkB)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if got := pair.bindA.openCount(); got != 2 {
|
||||
t.Fatalf("give-up inside the shared window rebound: %d opens, want 2", got)
|
||||
}
|
||||
|
||||
// Next series: age the window as wall clocks would — heals again.
|
||||
pair.devA.giveUpRebind.last.Store(time.Now().Add(-RekeyAttemptTime - time.Second).Unix())
|
||||
triggerGiveUp(t, pair.devA, pair.pkB)
|
||||
waitOpens(t, pair.bindA, 3)
|
||||
}
|
||||
|
||||
// The early trigger must NOT fire before enough initiations went unanswered,
|
||||
// even against a provably dead session.
|
||||
func TestEarlyRebindNeedsMinAttempts(t *testing.T) {
|
||||
pair := newGiveUpPair(t, true)
|
||||
peer := peerOf(t, pair.devA, pair.pkB)
|
||||
|
||||
peer.handshake.mutex.Lock()
|
||||
peer.handshake.lastSentHandshake = time.Now().Add(-2 * RekeyTimeout)
|
||||
peer.handshake.mutex.Unlock()
|
||||
peer.timers.handshakeAttempts.Store(0) // this expiry brings it to 1 (< min)
|
||||
expiredRetransmitHandshake(peer)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if got := pair.bindA.openCount(); got != 1 {
|
||||
t.Fatalf("early rebind fired below the attempt floor: %d opens, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh session (live keypair, recent handshake) must keep the retry branch
|
||||
// byte-for-byte upstream even past the attempt floor: no rebind.
|
||||
func TestEarlyRebindFreshSessionNoop(t *testing.T) {
|
||||
pair := newGiveUpPair(t, false)
|
||||
establishTunnel(t, pair)
|
||||
peer := peerOf(t, pair.devA, pair.pkB)
|
||||
|
||||
opens := pair.bindA.openCount()
|
||||
peer.handshake.mutex.Lock()
|
||||
peer.handshake.lastSentHandshake = time.Now().Add(-2 * RekeyTimeout)
|
||||
peer.handshake.mutex.Unlock()
|
||||
peer.timers.handshakeAttempts.Store(earlyGiveUpMinAttempts)
|
||||
expiredRetransmitHandshake(peer)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if got := pair.bindA.openCount(); got != opens {
|
||||
t.Fatalf("early rebind fired on a fresh session: %d opens, want %d", got, opens)
|
||||
}
|
||||
}
|
||||
|
||||
// Nudge racing Close: no panic, no deadlock, no race-detector report.
|
||||
func TestNudgeRacesClose(t *testing.T) {
|
||||
for i := 0; i < 25; i++ {
|
||||
pair := newGiveUpPair(t, false)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pair.devA.RebindIfSessionStale()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pair.devA.Close()
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// Nudge racing Down/Up (the SPEC 020 suspend/resume shape): the device must
|
||||
// end consistent — up, with a live bind.
|
||||
func TestNudgeRacesSuspend(t *testing.T) {
|
||||
for i := 0; i < 25; i++ {
|
||||
pair := newGiveUpPair(t, false)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pair.devA.RebindIfSessionStale()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := pair.devA.Down(); err != nil {
|
||||
t.Errorf("Down: %v", err)
|
||||
}
|
||||
if err := pair.devA.Up(); err != nil {
|
||||
t.Errorf("Up: %v", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
pair.devA.net.RLock()
|
||||
bindAlive := pair.devA.net.bind != nil
|
||||
pair.devA.net.RUnlock()
|
||||
if !bindAlive || !pair.devA.isUp() {
|
||||
t.Fatalf("iteration %d: device inconsistent after nudge/suspend race (bind=%v up=%v)",
|
||||
i, bindAlive, pair.devA.isUp())
|
||||
}
|
||||
pair.devA.Close()
|
||||
pair.devB.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,13 @@ func expiredRetransmitHandshake(peer *Peer) {
|
|||
peer.timers.handshakeAttempts.Add(1)
|
||||
peer.device.log.Verbosef("%s - Handshake did not complete after %d seconds, retrying (try %d)", peer, int(RekeyTimeout.Seconds()), peer.timers.handshakeAttempts.Load()+1)
|
||||
|
||||
/* lx: SPEC 041 v2 — early self-heal: >=3 unanswered initiations against
|
||||
* a provably dead session (no live keypair, or last handshake older
|
||||
* than RejectAfterTime) prove the 5-tuple dead without waiting out the
|
||||
* full cycle. Rebind now (debounced with the give-up trigger below);
|
||||
* the retry cycle itself continues untouched. */
|
||||
peer.device.maybeEarlyGiveUpRebind(peer)
|
||||
|
||||
/* We clear the endpoint address src address, in case this is the cause of trouble. */
|
||||
peer.markEndpointSrcForClearing()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue