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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue