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

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
}