lx(1.14): SPEC 019 urltest mode + sticky load-balancing

Add a `mode` to the urltest group so it can distribute traffic instead of only
picking the lowest-delay node, with optional per-flow stickiness.

- mode: least_test (default, unchanged) | round_robin (rotate across live nodes)
  | least_connection (reserved, phase 2 — rejected at config time).
- round_robin selects once per connection over the tag-sorted live set (nodes with
  a fresh URL-test result supporting the network); UDP/QUIC sessions stay on one
  node; first usable outbound is the fallback when nothing is live. The legacy
  selectedOutbound* cache path is untouched — balancing is a separate branch in
  DialContext/ListenPacket.
- sticky {mode, timeout, cap, hash}: binds one flow to one node. hash components
  process|domain|source_ip|dest_ip|dest_port concatenate in order; absent -> "",
  all-empty key -> one fixed node (keyless flows never rotate). mode jumphash
  (default, stateless consistent hash — ~1/n remap on node-set change) or ttlmap
  (key->node table, lazy + ticker eviction, 2000 LRU cap, 10m TTL, dead-node re-pin).

Reuses the existing urltest health ticker/history as the single liveness source;
no new probing. Now() reports the last-picked tag in balanced modes.

Tests (go test -race, 15 cases): distribution, dead-node skip, all-dead fallback,
jumphash stability + empty-key fixed node, ttlmap stick/expire/cap/dead-repick,
key building, validation. The race detector caught a real bug in the sticky
sweeper (read t.ticker unlocked while close() nilled it) — fixed by passing the
channels into the goroutine, mirroring URLTestGroup.loopCheck.

Also folds the SPEC 016 connections-map mutex (ebf9cc07) into the rc.11 changelog
section, which had not yet shipped in a release.
This commit is contained in:
Leadaxe
2026-06-28 01:10:17 +03:00
parent ebf9cc0768
commit 5ebff914fc
9 changed files with 1089 additions and 29 deletions
+66 -1
View File
@@ -14,7 +14,15 @@
"interval": "",
"tolerance": 0,
"idle_timeout": "",
"interrupt_exist_connections": false
"interrupt_exist_connections": false,
"mode": "least_test",
"sticky": {
"mode": "jumphash",
"timeout": "10m",
"cap": 2000,
"hash": ["process", "domain"]
}
}
```
@@ -47,3 +55,60 @@ The idle timeout. `30m` will be used if empty.
Interrupt existing connections when the selected outbound has changed.
Only inbound connections are affected by this setting, internal connections will always be interrupted.
#### mode
!!! quote "sing-box-lx"
An `lx` extension (SPEC 019), not present in upstream sing-box.
Load-balancing mode — how a node is chosen per connection:
- `least_test` (default, also when empty): pick the lowest-delay node. This is upstream's
behaviour; `sticky` is ignored.
- `round_robin`: rotate across the live nodes (those with a fresh URL-test result that
support the network). Selection happens once per connection; a UDP/QUIC session stays on
one node.
- `least_connection`: not implemented yet (planned); configuring it is an error.
When no node is live, the first usable outbound is used as a fallback.
#### sticky
!!! quote "sing-box-lx"
An `lx` extension (SPEC 019), not present in upstream sing-box.
Binds one flow to one node in `round_robin` / `least_connection` mode, so the same key
(e.g. the same destination domain) always reaches the same node. Omit it (or leave `hash`
empty) for no stickiness.
##### sticky.mode
The binding mechanism:
- `jumphash` (default): stateless consistent hash over the live nodes. No table; changing
the live-node count remaps only ~1/n of keys. `timeout` / `cap` are ignored.
- `ttlmap`: a `key → node` table. A key sticks to its node while that node is alive and the
entry is younger than `timeout`; a dead node re-pins to a surviving one.
##### sticky.timeout
`ttlmap` entry TTL. `10m` will be used if empty. Ignored for `jumphash`.
##### sticky.cap
`ttlmap` maximum entries; the oldest are evicted past this. `2000` will be used if empty.
Ignored for `jumphash`.
##### sticky.hash
Key components, concatenated in order. An absent component contributes an empty string;
when all are empty the key is `""`, which maps to a single fixed node (so keyless flows do
not rotate). Allowed values:
- `process`: the source process (Android package name, else executable path).
- `domain`: the destination domain (empty for IP destinations).
- `source_ip`: the source IP.
- `dest_ip`: the destination IP.
- `dest_port`: the destination port.