Skip to content

Commit 0352b30

Browse files
danielpaulusclaude
andcommitted
fix(tunnel): concurrent bounded liveness probes + failure hysteresis
Address two robustness defects in the TunnelManager self-heal path (#765): - Probes were run serially inside UpdateTunnels; each dead-but-still-routed tunnel blackholes the SYN for the full probeDialTimeout, so N dead tunnels could stall tunnel creation for newly connected devices by up to probeDialTimeout*N. Probes now run concurrently, bounded by maxConcurrentProbes, so a batch of dead tunnels can't serialize into a multi-second stall. - A single failed probe immediately tore down an otherwise healthy tunnel. A tunnel is now only rebuilt after probeFailureThreshold (2) consecutive failures, and the counter resets on any successful probe, so a momentary route hiccup or load spike no longer destroys a healthy tunnel. Also prune unsupportedDevices and probeFailures for disconnected devices so these maps stay bounded by the current device count instead of every udid ever seen, and so a device that was unsupported, upgraded to a tunnel-capable iOS, and reconnected is retried instead of skipped for the process lifetime. Tests cover the two-failure threshold, transient-failure absorption, and unsupported-on-disconnect pruning; all pass under -race. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
1 parent c4bca35 commit 0352b30

2 files changed

Lines changed: 229 additions & 23 deletions

File tree

ios/tunnel/tunnel_api.go

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,12 @@ type TunnelManager struct {
340340
unsupportedDevices map[string]bool
341341
// lastProbe tracks when each tunnel was last liveness-probed so probes run
342342
// at most once per probeInterval.
343-
lastProbe map[string]time.Time
343+
lastProbe map[string]time.Time
344+
// probeFailures counts consecutive failed liveness probes per udid. A tunnel
345+
// is only torn down after probeFailureThreshold consecutive failures so a
346+
// single transient probe error (a momentary route hiccup or load spike)
347+
// doesn't destroy an otherwise healthy tunnel. A successful probe resets it.
348+
probeFailures map[string]int
344349
probeInterval time.Duration
345350
startTunnelTimeout time.Duration
346351
firstUpdateCompleted bool
@@ -387,6 +392,7 @@ func newTunnelManager(pm PairRecordManager, userspaceTUN bool, udidFilter string
387392
failedDevices: map[string]failedDevice{},
388393
unsupportedDevices: map[string]bool{},
389394
lastProbe: map[string]time.Time{},
395+
probeFailures: map[string]int{},
390396
probeInterval: defaultProbeInterval,
391397
startTunnelTimeout: 10 * time.Second,
392398
userspaceTUN: userspaceTUN,
@@ -460,19 +466,18 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
460466
// Liveness-probe existing tunnel records of still-connected devices: a
461467
// tunnel can die without a usbmux disconnect (quick reboot, transport
462468
// death), leaving a stale record that would otherwise be served forever. A
463-
// failed probe tears the record down here so the create loop below rebuilds
464-
// it in the same cycle. Records of vanished devices are handled by the
465-
// disconnect teardown at the end.
469+
// tunnel that fails probeFailureThreshold consecutive probes is torn down
470+
// here so the create loop below rebuilds it in the same cycle. Records of
471+
// vanished devices are handled by the disconnect teardown at the end.
472+
//
473+
// Probes run concurrently (bounded) because a dead-but-still-routed tunnel
474+
// blackholes the SYN and burns the full probeDialTimeout; probing serially
475+
// would let N dead tunnels delay tunnel creation for newly connected devices
476+
// by up to probeDialTimeout*N.
466477
now := time.Now()
467-
for udid, tun := range localTunnels {
468-
if !currentUDIDs[udid] || !m.shouldProbe(udid, now) {
469-
continue
470-
}
471-
if err := m.pr.Probe(tun); err != nil {
472-
golog.Warn("tunnel failed liveness probe, restarting it", "module", logModule, "udid", udid, "error", err)
473-
_ = m.stopTunnel(tun)
474-
delete(localTunnels, udid)
475-
}
478+
for udid := range m.probeTunnels(localTunnels, currentUDIDs, now) {
479+
_ = m.stopTunnel(localTunnels[udid])
480+
delete(localTunnels, udid)
476481
}
477482

478483
for _, d := range devices.DeviceList {
@@ -541,6 +546,21 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
541546
delete(m.failedDevices, udid)
542547
}
543548
}
549+
// Prune per-device bookkeeping for devices that are no longer connected so
550+
// these maps stay bounded by the current device count instead of growing
551+
// with every udid ever seen. Pruning unsupportedDevices on disconnect also
552+
// lets a device that was unsupported, then upgraded to a tunnel-capable iOS
553+
// and reconnected, be retried instead of skipped for the rest of the process.
554+
for udid := range m.unsupportedDevices {
555+
if !currentUDIDs[udid] {
556+
delete(m.unsupportedDevices, udid)
557+
}
558+
}
559+
for udid := range m.probeFailures {
560+
if !currentUDIDs[udid] {
561+
delete(m.probeFailures, udid)
562+
}
563+
}
544564
m.firstUpdateCompleted = true
545565
m.mux.Unlock()
546566
return nil
@@ -574,6 +594,61 @@ func (m *TunnelManager) shouldProbe(udid string, now time.Time) bool {
574594
return true
575595
}
576596

597+
// probeTunnels liveness-probes every due tunnel of a still-connected device and
598+
// returns the set of udids that should be torn down. Probes run concurrently
599+
// with at most maxConcurrentProbes in flight so a batch of dead-but-routed
600+
// tunnels (each of which blackholes the SYN for the full probeDialTimeout)
601+
// can't serialize into a multi-second stall of the update loop. A tunnel is only
602+
// reported dead after probeFailureThreshold consecutive failures; a success
603+
// resets its counter, so a single transient probe error is absorbed.
604+
func (m *TunnelManager) probeTunnels(localTunnels map[string]Tunnel, currentUDIDs map[string]bool, now time.Time) map[string]bool {
605+
type probeResult struct {
606+
udid string
607+
err error
608+
}
609+
results := make(chan probeResult)
610+
sem := make(chan struct{}, maxConcurrentProbes)
611+
var wg sync.WaitGroup
612+
for udid, tun := range localTunnels {
613+
if !currentUDIDs[udid] || !m.shouldProbe(udid, now) {
614+
continue
615+
}
616+
wg.Add(1)
617+
go func(udid string, tun Tunnel) {
618+
defer wg.Done()
619+
sem <- struct{}{}
620+
defer func() { <-sem }()
621+
results <- probeResult{udid: udid, err: m.pr.Probe(tun)}
622+
}(udid, tun)
623+
}
624+
go func() {
625+
wg.Wait()
626+
close(results)
627+
}()
628+
629+
dead := map[string]bool{}
630+
m.mux.Lock()
631+
defer m.mux.Unlock()
632+
for r := range results {
633+
if r.err == nil {
634+
delete(m.probeFailures, r.udid)
635+
continue
636+
}
637+
m.probeFailures[r.udid]++
638+
fails := m.probeFailures[r.udid]
639+
if fails < probeFailureThreshold {
640+
golog.Warn("tunnel failed liveness probe, will retry before restarting it",
641+
"module", logModule, "udid", r.udid, "consecutiveFailures", fails, "threshold", probeFailureThreshold, "error", r.err)
642+
continue
643+
}
644+
golog.Warn("tunnel failed liveness probe repeatedly, restarting it",
645+
"module", logModule, "udid", r.udid, "consecutiveFailures", fails, "error", r.err)
646+
delete(m.probeFailures, r.udid)
647+
dead[r.udid] = true
648+
}
649+
return dead
650+
}
651+
577652
// failedDeviceBackoff returns how long to wait before retrying a device after
578653
// failCount consecutive failures: 30s, 60s, 120s, 240s, capped at 5 minutes.
579654
func failedDeviceBackoff(failCount int) time.Duration {
@@ -598,6 +673,7 @@ func (m *TunnelManager) RemoveTunnel(ctx context.Context, serialNumber string) e
598673
delete(m.tunnels, serialNumber)
599674
}
600675
delete(m.lastProbe, serialNumber)
676+
delete(m.probeFailures, serialNumber)
601677
m.mux.Unlock()
602678

603679
if !exists {
@@ -612,6 +688,7 @@ func (m *TunnelManager) stopTunnel(t Tunnel) error {
612688
golog.Info("stopping tunnel", "module", logModule, "udid", t.Udid)
613689
delete(m.tunnels, t.Udid)
614690
delete(m.lastProbe, t.Udid)
691+
delete(m.probeFailures, t.Udid)
615692
m.mux.Unlock()
616693

617694
return t.Close()
@@ -673,15 +750,29 @@ type tunnelProber interface {
673750
}
674751

675752
// defaultProbeInterval is how often the TunnelManager liveness-probes each
676-
// tunnel record. Combined with the probe's short dial timeout, a dead tunnel
677-
// is detected and rebuilt well within a minute instead of never.
753+
// tunnel record. Combined with the probe's short dial timeout and the
754+
// probeFailureThreshold consecutive-failure requirement, a persistently dead
755+
// tunnel is detected and rebuilt within roughly a minute instead of never,
756+
// while a single transient probe error never tears down a healthy tunnel.
678757
const defaultProbeInterval = 30 * time.Second
679758

680759
// probeDialTimeout bounds the liveness probe's TCP connect. A healthy tunnel
681760
// answers in milliseconds; a dead-but-still-routed one swallows the SYN, so a
682761
// short timeout is enough to tell them apart without stalling UpdateTunnels.
683762
const probeDialTimeout = 5 * time.Second
684763

764+
// maxConcurrentProbes caps how many liveness probes run at once. Probes are
765+
// concurrent so a batch of dead-but-routed tunnels can't serialize into a
766+
// probeDialTimeout*N stall, but bounded so a large fleet can't open an
767+
// unbounded number of dial sockets in one cycle.
768+
const maxConcurrentProbes = 8
769+
770+
// probeFailureThreshold is the number of consecutive failed liveness probes
771+
// required before a tunnel is torn down and rebuilt. Requiring two failures
772+
// keeps a single transient probe error (a momentary route hiccup) from
773+
// destroying a healthy tunnel.
774+
const probeFailureThreshold = 2
775+
685776
// rsdProber is the default tunnelProber: a short-timeout TCP dial of the
686777
// tunnel's RSD endpoint over the kernel TUN route.
687778
type rsdProber struct {

ios/tunnel/tunnel_manager_robustness_test.go

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,24 @@ func (f *fakeStarter) callsFor(udid string) int {
5151
}
5252

5353
type fakeProber struct {
54+
mu sync.Mutex
5455
err error
5556
probed []string
5657
}
5758

5859
func (f *fakeProber) Probe(t Tunnel) error {
60+
f.mu.Lock()
5961
f.probed = append(f.probed, t.Udid)
62+
f.mu.Unlock()
6063
return f.err
6164
}
6265

66+
func (f *fakeProber) probedUDIDs() []string {
67+
f.mu.Lock()
68+
defer f.mu.Unlock()
69+
return append([]string(nil), f.probed...)
70+
}
71+
6372
// robustnessManager builds a fully faked TunnelManager that probes on every
6473
// UpdateTunnels cycle and resolves every device to iOS 18.0.0.
6574
func robustnessManager(ts tunnelStarter, pr tunnelProber, entries ...ios.DeviceEntry) *TunnelManager {
@@ -71,6 +80,7 @@ func robustnessManager(ts tunnelStarter, pr tunnelProber, entries ...ios.DeviceE
7180
failedDevices: map[string]failedDevice{},
7281
unsupportedDevices: map[string]bool{},
7382
lastProbe: map[string]time.Time{},
83+
probeFailures: map[string]int{},
7484
probeInterval: time.Nanosecond,
7585
startTunnelTimeout: time.Second,
7686
productVersion: func(ios.DeviceEntry) (*semver.Version, error) {
@@ -79,20 +89,32 @@ func robustnessManager(ts tunnelStarter, pr tunnelProber, entries ...ios.DeviceE
7989
}
8090
}
8191

82-
// A tunnel record whose device is still connected but whose probe fails must be
83-
// torn down and rebuilt in the same update cycle — the agent-side self-heal
84-
// from issue #765.
92+
// A tunnel record whose device is still connected but whose probe keeps failing
93+
// must be torn down and rebuilt — the agent-side self-heal from issue #765.
94+
// Teardown requires probeFailureThreshold consecutive failures, so it happens on
95+
// the second failing cycle, not the first.
8596
func TestUpdateTunnelsRebuildsDeadTunnel(t *testing.T) {
8697
starter := &fakeStarter{rsdPort: 4321}
8798
prober := &fakeProber{err: errors.New("connection timed out")}
8899
tm := robustnessManager(starter, prober, devEntry("dead-1", "USB"))
89100
var closed int
90101
tm.tunnels["dead-1"] = Tunnel{Udid: "dead-1", Address: "fd00::1", RsdPort: 1234, closer: func() error { closed++; return nil }}
91102

103+
// First failing probe: below the threshold, so the tunnel survives.
92104
if err := tm.UpdateTunnels(context.Background()); err != nil {
93-
t.Fatalf("UpdateTunnels: %v", err)
105+
t.Fatalf("UpdateTunnels cycle 1: %v", err)
106+
}
107+
if closed != 0 {
108+
t.Fatalf("after one failed probe closer called %d times, want 0 (needs %d consecutive)", closed, probeFailureThreshold)
109+
}
110+
if got := starter.callsFor("dead-1"); got != 0 {
111+
t.Fatalf("after one failed probe tunnel restarted %d times, want 0", got)
94112
}
95113

114+
// Second consecutive failing probe: threshold reached, tear down and rebuild.
115+
if err := tm.UpdateTunnels(context.Background()); err != nil {
116+
t.Fatalf("UpdateTunnels cycle 2: %v", err)
117+
}
96118
if closed != 1 {
97119
t.Fatalf("dead tunnel closer called %d times, want 1", closed)
98120
}
@@ -105,6 +127,38 @@ func TestUpdateTunnelsRebuildsDeadTunnel(t *testing.T) {
105127
}
106128
}
107129

130+
// A single transient probe failure followed by a success must NOT tear down the
131+
// tunnel: the consecutive-failure counter resets on success (issue #765 —
132+
// avoiding false-positive teardown of healthy tunnels under momentary load).
133+
func TestUpdateTunnelsAbsorbsTransientProbeFailure(t *testing.T) {
134+
starter := &fakeStarter{rsdPort: 4321}
135+
prober := &fakeProber{err: errors.New("temporary hiccup")}
136+
tm := robustnessManager(starter, prober, devEntry("flap-1", "USB"))
137+
var closed int
138+
tm.tunnels["flap-1"] = Tunnel{Udid: "flap-1", Address: "fd00::1", RsdPort: 1234, closer: func() error { closed++; return nil }}
139+
140+
// One failing cycle (count=1), then the probe recovers.
141+
if err := tm.UpdateTunnels(context.Background()); err != nil {
142+
t.Fatalf("UpdateTunnels cycle 1: %v", err)
143+
}
144+
prober.mu.Lock()
145+
prober.err = nil
146+
prober.mu.Unlock()
147+
if err := tm.UpdateTunnels(context.Background()); err != nil {
148+
t.Fatalf("UpdateTunnels cycle 2: %v", err)
149+
}
150+
151+
if closed != 0 {
152+
t.Fatalf("tunnel torn down after a single transient failure, closer called %d times", closed)
153+
}
154+
if got := starter.callsFor("flap-1"); got != 0 {
155+
t.Fatalf("tunnel restarted %d times after transient failure, want 0", got)
156+
}
157+
if n := tm.probeFailures["flap-1"]; n != 0 {
158+
t.Fatalf("probeFailures[flap-1] = %d after a success, want 0 (counter must reset)", n)
159+
}
160+
}
161+
108162
// A healthy tunnel record must survive the probe untouched: no teardown, no
109163
// restart.
110164
func TestUpdateTunnelsKeepsHealthyTunnel(t *testing.T) {
@@ -118,8 +172,8 @@ func TestUpdateTunnelsKeepsHealthyTunnel(t *testing.T) {
118172
t.Fatalf("UpdateTunnels: %v", err)
119173
}
120174

121-
if len(prober.probed) != 1 || prober.probed[0] != "ok-1" {
122-
t.Fatalf("probed = %v, want [ok-1]", prober.probed)
175+
if probed := prober.probedUDIDs(); len(probed) != 1 || probed[0] != "ok-1" {
176+
t.Fatalf("probed = %v, want [ok-1]", probed)
123177
}
124178
if closed != 0 {
125179
t.Fatalf("healthy tunnel closer called %d times, want 0", closed)
@@ -144,8 +198,8 @@ func TestUpdateTunnelsDoesNotProbeDisconnectedDevice(t *testing.T) {
144198
t.Fatalf("UpdateTunnels: %v", err)
145199
}
146200

147-
if len(prober.probed) != 0 {
148-
t.Fatalf("probed = %v, want none for a disconnected device", prober.probed)
201+
if probed := prober.probedUDIDs(); len(probed) != 0 {
202+
t.Fatalf("probed = %v, want none for a disconnected device", probed)
149203
}
150204
if _, ok := tm.tunnels["gone-1"]; ok {
151205
t.Fatal("disconnected device's tunnel should have been torn down")
@@ -274,3 +328,64 @@ func TestUpdateTunnelsUdidFilter(t *testing.T) {
274328
t.Fatalf("expected tunnels for both devices, got %v", tmAll.tunnels)
275329
}
276330
}
331+
332+
// mutableLister lets a test change the connected-device list between cycles.
333+
type mutableLister struct {
334+
mu sync.Mutex
335+
entries []ios.DeviceEntry
336+
}
337+
338+
func (l *mutableLister) ListDevices() (ios.DeviceList, error) {
339+
l.mu.Lock()
340+
defer l.mu.Unlock()
341+
return ios.DeviceList{DeviceList: append([]ios.DeviceEntry(nil), l.entries...)}, nil
342+
}
343+
344+
func (l *mutableLister) set(entries ...ios.DeviceEntry) {
345+
l.mu.Lock()
346+
defer l.mu.Unlock()
347+
l.entries = entries
348+
}
349+
350+
// An unsupported classification must not survive a disconnect: once the device
351+
// leaves usbmux the entry is pruned, so reconnecting (e.g. after an iOS upgrade
352+
// to a tunnel-capable version) is retried instead of skipped forever. This also
353+
// bounds the unsupportedDevices map by the current device count rather than by
354+
// every udid ever seen (issue #523 growth follow-up).
355+
func TestUpdateTunnelsPrunesUnsupportedOnDisconnect(t *testing.T) {
356+
lister := &mutableLister{}
357+
lister.set(devEntry("up-1", "USB"))
358+
starter := &fakeStarter{err: fmt.Errorf("manualPairingTunnelStart: %w 16.6.0", ErrUnsupportedVersion)}
359+
tm := robustnessManager(starter, &fakeProber{})
360+
tm.dl = lister
361+
362+
// Cycle 1: device is unsupported, gets marked and skipped.
363+
if err := tm.UpdateTunnels(context.Background()); err != nil {
364+
t.Fatalf("UpdateTunnels cycle 1: %v", err)
365+
}
366+
if !tm.unsupportedDevices["up-1"] {
367+
t.Fatal("device should be marked unsupported after cycle 1")
368+
}
369+
370+
// Cycle 2: device disconnects; the unsupported entry must be pruned.
371+
lister.set()
372+
if err := tm.UpdateTunnels(context.Background()); err != nil {
373+
t.Fatalf("UpdateTunnels cycle 2: %v", err)
374+
}
375+
if tm.unsupportedDevices["up-1"] {
376+
t.Fatal("unsupported entry must be pruned once the device disconnects")
377+
}
378+
379+
// Cycle 3: device reconnects on a supported version; it must be retried.
380+
lister.set(devEntry("up-1", "USB"))
381+
starter.mu.Lock()
382+
starter.err = nil
383+
starter.rsdPort = 9999
384+
starter.mu.Unlock()
385+
if err := tm.UpdateTunnels(context.Background()); err != nil {
386+
t.Fatalf("UpdateTunnels cycle 3: %v", err)
387+
}
388+
if _, ok := tm.tunnels["up-1"]; !ok {
389+
t.Fatal("reconnected (now-supported) device should get a tunnel, not stay skipped")
390+
}
391+
}

0 commit comments

Comments
 (0)