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:
Leadaxe 2026-08-02 01:40:01 +03:00
parent c4e0bcf768
commit 3909adbf87
5 changed files with 444 additions and 60 deletions

View file

@ -123,17 +123,14 @@ type Device struct {
ipackets [5]*obfChain ipackets [5]*obfChain
// lx: SPEC 041 — passive self-heal on handshake give-up. When a peer's // lx: SPEC 041 — passive self-heal state: reopen the bind once (fresh
// handshake retry cycle exhausts (the give-up branch of // ephemeral port when freshPort is set) and immediately re-initiate, to
// expiredRetransmitHandshake), the device reopens its bind once — with a // heal dead per-flow path state (an expired NAT mapping or a poisoned DPI
// fresh ephemeral port when freshPort is set — and immediately // flow entry) that otherwise pins every retry to the same dead 5-tuple
// re-initiates. Heals dead per-flow path state (an expired NAT mapping or // until a manual reconnect. The whole mechanism — three triggers (giveup /
// a poisoned DPI flow entry) that otherwise pins every retry to the same // early / nudge) sharing this state and its debounce — lives in
// dead 5-tuple until a manual reconnect. Zero cost while healthy: no // lx_giveup_rebind.go. Enabled by default; sing-box decides freshPort
// timers, no goroutines — the trigger is the existing give-up event, // from whether the user pinned listen_port.
// 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.
giveUpRebind struct { giveUpRebind struct {
enabled atomic.Bool enabled atomic.Bool
freshPort atomic.Bool freshPort atomic.Bool
@ -803,55 +800,6 @@ func (device *Device) BindUpdate() error {
return nil 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 { func (device *Device) BindClose() error {
device.net.Lock() device.net.Lock()
err := closeBindLocked(device) err := closeBindLocked(device)

View 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
View 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
}

View 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()
}
}

View file

@ -111,6 +111,13 @@ func expiredRetransmitHandshake(peer *Peer) {
peer.timers.handshakeAttempts.Add(1) 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) 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. */ /* We clear the endpoint address src address, in case this is the cause of trouble. */
peer.markEndpointSrcForClearing() peer.markEndpointSrcForClearing()