Skip to content

Commit 9f3c18e

Browse files
authored
fix(agent): scan neighbors per interface with own timeout (#1388)
The neighbor scan ran as a single shell loop over all interfaces with one shared 10 s timeout, and the partial output was discarded when the deadline killed it. A slow 5 GHz radio on a DFS channel (UniFi 6 Lite, mt7621) starved the whole loop, so the unit reported no neighbors at all. Each interface now scans as its own command with its own 8 s budget. Refs #1338
1 parent a4d7585 commit 9f3c18e

4 files changed

Lines changed: 194 additions & 12 deletions

File tree

‎agent/probe/local.go‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -445,16 +445,30 @@ func (p *Prober) probeWireless(ctx context.Context, full bool) *WirelessData {
445445
p.radiosMu.Unlock()
446446
}
447447
}
448+
// `iw dev` a secas, barato y estable: alimenta con UN solo spawn los
449+
// BSSIDs propios (#1087) y la lista de interfaces a escanear (#1338).
450+
iwDev := p.runBest(ctx, CmdIwDev, 5*time.Second)
448451
// Scan pasivo de vecinos (#452): SOLO en sondeo completo y con
449452
// throttle (#591) para no sacar la radio del canal en cada push.
450-
if p.scanDue() {
451-
if out := p.runBest(ctx, CmdScan, 10*time.Second); out != "" {
452-
wd.Scans = ParseScan(out)
453+
// #1338: un comando POR INTERFAZ, cada una con su propio presupuesto
454+
// (8 s). Antes el bucle completo compartía un timeout de 10 s y, al
455+
// expirar, la salida parcial se descartaba entera: una radio lenta
456+
// (5 GHz en canal DFS, scan pasivo canal a canal) dejaba la unidad
457+
// sin NINGÚN vecino aunque las demás interfaces sí hubieran
458+
// escaneado con éxito.
459+
if p.scanDue() && iwDev != "" {
460+
var sb strings.Builder
461+
for _, iface := range IwDevIfaces(iwDev) {
462+
sb.WriteString("==IFACE==" + iface + "\n")
463+
sb.WriteString(p.runBest(ctx, CmdScanIface(iface), scanIfaceTimeout))
464+
}
465+
if scans := ParseScan(sb.String()); len(scans) > 0 {
466+
wd.Scans = scans
453467
}
454468
}
455-
// BSSIDs propios (#1087): `iw dev` sin scan, barato y estable.
456-
if out := p.runBest(ctx, CmdIwDev, 5*time.Second); out != "" {
457-
wd.OwnBssids = ParseIwDev(out)
469+
// BSSIDs propios (#1087), del mismo `iw dev` de arriba.
470+
if iwDev != "" {
471+
wd.OwnBssids = ParseIwDev(iwDev)
458472
}
459473
// Survey en vivo (#1214): ocupación por canal para la lente "Ahora"
460474
// del server en unidades agent-only (sin SSH). Texto crudo: el

‎agent/probe/probe.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ const (
9595
// CmdScan (#452): scan pasivo de vecinos con `iw dev`. Lista interfaces,
9696
// emite "==IFACE==<iface>" antes de cada scan y parsea BSS/freq/signal/SSID.
9797
// Si `iw` no está disponible, la sección Scans queda vacía (best-effort).
98+
//
99+
// DEPRECATED (#1338): el bucle monolítico corría con UN solo timeout para
100+
// todas las interfaces y, al expirar, la salida parcial se descartaba
101+
// entera. Una radio lenta (5 GHz en canal DFS, scan pasivo canal a canal)
102+
// dejaba la unidad sin NINGÚN vecino, aunque las demás interfaces sí
103+
// hubieran escaneado. El agente escanea ahora por interfaz con
104+
// CmdScanIface, cada una con su propio presupuesto. Se conserva la
105+
// constante por compatibilidad del módulo (consumidores vendorizados).
98106
CmdScan = `for i in $(iw dev 2>/dev/null | awk '/Interface / {print $2}'); do echo "==IFACE==$i"; iw dev "$i" scan 2>/dev/null; done`
99107
// CmdSurvey (#1214): `iw dev <iface> survey dump` por interfaz, con el
100108
// mismo marcador "==IFACE==" que CmdScan. El server parsea el texto
@@ -106,6 +114,13 @@ const (
106114
// su MAC y SSID: fuente de los BSSIDs propios para el análisis de
107115
// canales. Barato (no saca la radio del canal).
108116
CmdIwDev = "iw dev 2>/dev/null"
117+
// scanIfaceTimeout (#1338): presupuesto POR INTERFAZ del scan de vecinos.
118+
// Una radio 5 GHz en canal DFS escanea en pasivo canal a canal y puede
119+
// tardar varios segundos; con el presupuesto único de 10 s para todas,
120+
// esa radio lenta mataba el bucle y se perdían también los scans ya
121+
// hechos. Cada interfaz tiene ahora su propio tiempo y un fallo o un
122+
// cuelgue solo vacía su propia sección.
123+
scanIfaceTimeout = 8 * time.Second
109124
// CmdRadioSections (#500): secciones wifi-device de UCI con su banda, para
110125
// resolver "2.4 GHz" → radio0 (la relación iface→sección no sale de iwinfo).
111126
CmdRadioSections = `uci -q show wireless 2>/dev/null | grep -E "=wifi-device$|\.band=|\.hwmode="`
@@ -186,6 +201,14 @@ const (
186201
CmdEthtoolSFP = "ethtool -m %s 2>/dev/null || true"
187202
)
188203

204+
// CmdScanIface (#1338): scan pasivo de UNA interfaz. El agente lo lanza una
205+
// vez por interfaz de `iw dev` (en vez del bucle monolítico de CmdScan) para
206+
// que cada radio tenga su propio presupuesto de tiempo y una interfaz lenta
207+
// o colgada no se lleve por delante los resultados de las demás.
208+
func CmdScanIface(iface string) string {
209+
return `iw dev "` + iface + `" scan 2>/dev/null`
210+
}
211+
189212
// ---------------------------------------------------------------------------
190213
// Shapes parseados (salida de las sondas; contrato del payload del agente)
191214
// ---------------------------------------------------------------------------
@@ -1785,6 +1808,19 @@ func ParseIwDev(out string) []OwnBSSID {
17851808
return res
17861809
}
17871810

1811+
// IwDevIfaces (#1338) lista TODAS las interfaces de `iw dev` (cualquier
1812+
// tipo, no solo AP como ParseIwDev): son las candidatas a escanear vecinos.
1813+
func IwDevIfaces(out string) []string {
1814+
var res []string
1815+
for _, raw := range strings.Split(out, "\n") {
1816+
fields := strings.Fields(strings.TrimSpace(raw))
1817+
if len(fields) >= 2 && fields[0] == "Interface" {
1818+
res = append(res, fields[1])
1819+
}
1820+
}
1821+
return res
1822+
}
1823+
17881824
// FreqToChannel convierte frecuencia (MHz) a número de canal 802.11.
17891825
func FreqToChannel(freq int) int {
17901826
switch {

‎agent/probe/scan_periface_test.go‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package probe
2+
3+
// scan_periface_test.go — #1338: el scan de vecinos se lanzaba como UN solo
4+
// comando (`iw dev` + bucle sobre TODAS las interfaces) con UN solo timeout
5+
// de 10 s. Si una interfaz escanea lento (p. ej. una radio 5 GHz en canal
6+
// DFS, scan pasivo canal a canal), el timeout mataba el bucle entero y la
7+
// salida parcial —con las interfaces que SÍ habían escaneado— se descartaba:
8+
// la unidad no reportaba NINGÚN vecino (UniFi 6 Lite, mt7621). Ahora el scan
9+
// va por interfaz, cada una con su propio presupuesto de tiempo: una que
10+
// falla o se cuelga deja su sección vacía y no arrastra a las demás.
11+
12+
import (
13+
"context"
14+
"strings"
15+
"testing"
16+
"time"
17+
)
18+
19+
// runnerScanParcial simula una unidad con dos radios donde el scan de la
20+
// primera interfaz (5 GHz) muere por timeout y la segunda (2.4 GHz) devuelve
21+
// un vecino real.
22+
type runnerScanParcial struct {
23+
fakeRunner
24+
}
25+
26+
func (r runnerScanParcial) Run(ctx context.Context, cmd string, timeout time.Duration) (string, error) {
27+
if cmd == CmdScanIface("phy1-ap0") {
28+
return "", &fakeErr{cmd} // timeout del runner real: err y sin salida
29+
}
30+
return r.fakeRunner.Run(ctx, cmd, timeout)
31+
}
32+
33+
// TestScanPorInterfazSobreviveAFalloParcial: con el scan de la primera
34+
// interfaz fallando, los vecinos de la segunda deben llegar igual.
35+
func TestScanPorInterfazSobreviveAFalloParcial(t *testing.T) {
36+
iwDev := "phy#1\n" +
37+
"\tInterface phy1-ap0\n" +
38+
"\t\taddr 62:22:32:5b:5c:99\n" +
39+
"\t\tssid I1_LAN\n" +
40+
"\t\ttype AP\n" +
41+
"\t\tchannel 60 (5300 MHz), width: 80 MHz, center1: 5290 MHz\n" +
42+
"phy#0\n" +
43+
"\tInterface phy0-ap0\n" +
44+
"\t\taddr 60:22:32:5b:5c:98\n" +
45+
"\t\tssid I1_LAN\n" +
46+
"\t\ttype AP\n" +
47+
"\t\tchannel 5 (2432 MHz), width: 20 MHz, center1: 2432 MHz\n"
48+
// Salida real del scan en la U6 Lite del reportero (#1338), con las dos
49+
// líneas "last seen" (la de cabecera con [boottime] y la de señal).
50+
scan2g := "BSS c4:eb:39:0c:2b:c1(on phy0-ap0)\n" +
51+
"\tlast seen: 275247.799s [boottime]\n" +
52+
"\tTSF: 901954671988 usec (10d, 10:32:34)\n" +
53+
"\tfreq: 2437.0\n" +
54+
"\tbeacon interval: 100 TUs\n" +
55+
"\tcapability: ESS (0x1011)\n" +
56+
"\tsignal: -67.00 dBm\n" +
57+
"\tlast seen: 2140 ms ago\n" +
58+
"\tSSID: Vecino_24\n"
59+
run := runnerScanParcial{fakeRunner{outs: map[string]string{
60+
CmdIwDev: iwDev,
61+
CmdScanIface("phy0-ap0"): scan2g,
62+
}}}
63+
p := NewProber(run, Options{})
64+
65+
pl := p.Build(context.Background(), "u6lite", "test")
66+
if pl.Data.Wireless == nil {
67+
t.Fatal("sección wireless ausente")
68+
}
69+
scans := pl.Data.Wireless.Scans
70+
if len(scans) != 1 {
71+
t.Fatalf("el fallo del scan 5 GHz no debe arrastrar al 2.4 GHz: %+v", scans)
72+
}
73+
sc := scans[0]
74+
if sc.Iface != "phy0-ap0" || sc.BSSID != "C4:EB:39:0C:2B:C1" || sc.SSID != "Vecino_24" || sc.Channel != 6 || sc.Signal != -67 {
75+
t.Fatalf("vecino mal parseado: %+v", sc)
76+
}
77+
// Los BSSIDs propios salen del MISMO `iw dev` (sin segundo spawn).
78+
if len(pl.Data.Wireless.OwnBssids) != 2 {
79+
t.Fatalf("ownBssids perdidos: %+v", pl.Data.Wireless.OwnBssids)
80+
}
81+
}
82+
83+
// TestScanPorInterfazSinIw: sin `iw` en el equipo, ni scans ni BSSIDs propios
84+
// (best-effort, como antes).
85+
func TestScanPorInterfazSinIw(t *testing.T) {
86+
p := NewProber(fakeRunner{outs: map[string]string{}}, Options{})
87+
pl := p.Build(context.Background(), "sin-iw", "test")
88+
if pl.Data.Wireless != nil && len(pl.Data.Wireless.Scans) != 0 {
89+
t.Fatalf("sin iw no puede haber scans: %+v", pl.Data.Wireless.Scans)
90+
}
91+
}
92+
93+
// TestCmdScanIface: el comando por interfaz cita el nombre (como hacía el
94+
// bucle viejo con "$i").
95+
func TestCmdScanIface(t *testing.T) {
96+
cmd := CmdScanIface("phy0-ap0")
97+
if !strings.Contains(cmd, `iw dev "phy0-ap0" scan`) {
98+
t.Fatalf("comando inesperado: %q", cmd)
99+
}
100+
}
101+
102+
// TestScanPorInterfazThrottle: el throttle (scanDue) sigue gobernando el
103+
// scan aunque ahora se lance por interfaz: 2 interfaces = 2 comandos en la
104+
// primera Build y 0 en la segunda (dentro del intervalo).
105+
func TestScanPorInterfazThrottle(t *testing.T) {
106+
iwDev := "phy#0\n" +
107+
"\tInterface phy0-ap0\n\t\taddr 60:22:32:5b:5c:98\n\t\ttype AP\n" +
108+
"\tInterface phy0-ap1\n\t\taddr 62:22:32:5b:5c:98\n\t\ttype AP\n"
109+
run := &countingRunner{fakeRunner: fakeRunner{outs: map[string]string{
110+
CmdIwDev: iwDev,
111+
}}}
112+
p := NewProber(run, Options{})
113+
ctx := context.Background()
114+
115+
p.Build(ctx, "rt", "test")
116+
if run.scanCalls != 2 {
117+
t.Fatalf("primera Build debería escanear las 2 interfaces, scanCalls=%d", run.scanCalls)
118+
}
119+
p.Build(ctx, "rt", "test")
120+
if run.scanCalls != 2 {
121+
t.Fatalf("segunda Build dentro del intervalo NO debería escanear, scanCalls=%d", run.scanCalls)
122+
}
123+
p.ForceScan()
124+
p.Build(ctx, "rt", "test")
125+
if run.scanCalls != 4 {
126+
t.Fatalf("ForceScan debería reescanear las 2 interfaces, scanCalls=%d", run.scanCalls)
127+
}
128+
}

‎agent/probe/scan_throttle_test.go‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,33 @@ package probe
77

88
import (
99
"context"
10+
"strings"
1011
"testing"
1112
"time"
1213
)
1314

14-
// countingRunner cuenta las invocaciones de CmdScan sobre un fakeRunner.
15+
// countingRunner cuenta los scans por interfaz (#1338: antes era UNA llamada
16+
// a CmdScan; ahora es un `iw dev <iface> scan` por interfaz) sobre un
17+
// fakeRunner.
1518
type countingRunner struct {
1619
fakeRunner
1720
scanCalls int
1821
}
1922

2023
func (r *countingRunner) Run(ctx context.Context, cmd string, timeout time.Duration) (string, error) {
21-
if cmd == CmdScan {
24+
if strings.HasPrefix(cmd, `iw dev "`) && strings.HasSuffix(cmd, `" scan 2>/dev/null`) {
2225
r.scanCalls++
2326
}
2427
return r.fakeRunner.Run(ctx, cmd, timeout)
2528
}
2629

27-
// newScanProber devuelve un Prober cuyo runner solo responde a CmdScan (el
28-
// resto de sondas falla y Build sigue sin scans/radios: suficiente para
29-
// probar el throttle).
30+
// newScanProber devuelve un Prober cuyo runner solo responde a `iw dev` (una
31+
// interfaz wlan0) y a su scan (el resto de sondas falla y Build sigue sin
32+
// radios: suficiente para probar el throttle).
3033
func newScanProber() (*Prober, *countingRunner) {
3134
run := &countingRunner{fakeRunner: fakeRunner{outs: map[string]string{
32-
CmdScan: "==IFACE==wlan0\nBSS 00:11:22:33:44:55\n\tfreq: 2412\n\tsignal: -45.00 dBm\n\tSSID: test\n",
35+
CmdIwDev: "phy#0\n\tInterface wlan0\n\t\taddr 00:11:22:33:44:66\n\t\ttype AP\n",
36+
CmdScanIface("wlan0"): "BSS 00:11:22:33:44:55\n\tfreq: 2412\n\tsignal: -45.00 dBm\n\tSSID: test\n",
3337
}}}
3438
return NewProber(run, Options{}), run
3539
}

0 commit comments

Comments
 (0)