Skip to content

Commit c4bca35

Browse files
danielpaulusclaude
andcommitted
fix(tunnel): TunnelManager self-heals dead tunnels, stops retrying unsupported devices
Three robustness fixes to the tunnel agent's UpdateTunnels loop: 1. Liveness probing (#765): a tunnel that dies without a usbmux disconnect (quick reboot, transport death while the TUN route stays up) used to remain a stale record forever, because UpdateTunnels only creates tunnels for devices without a record and only tears down records of vanished devices. Existing records of still-connected devices are now probed every 30s behind a tunnelProber seam; the default prober is a short-timeout (5s) TCP dial of the RSD endpoint reusing the #764 dial helper. A failed probe tears the record down via the per-device stop machinery from #738 so the create path rebuilds it in the same cycle. 2. Unsupported-iOS-version classification (#523): sub-iOS17 devices used to produce a "failed to start tunnel ... unsupported iOS version" warning on every retry, forever, when mixed with iOS 17+ devices. manualPairingTunnelStart now wraps version-based failures in the ErrUnsupportedVersion sentinel; UpdateTunnels logs those once at info and permanently skips the device (restart the agent after an OS update). Transient errors keep the existing failedDevice backoff. 3. udid filter coverage (#607, #479): `ios tunnel start --udid=<udid>` restricts the agent to that one device via TunnelManager.udidFilter; add unit tests proving only the matching device is attempted and that an empty filter manages all devices. Also adds a productVersion seam (defaulting to ios.GetProductVersion) so the manager's start path is unit-testable without a device. Fixes #765, fixes #523, fixes #607, fixes #479 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
1 parent fda20a7 commit c4bca35

2 files changed

Lines changed: 394 additions & 4 deletions

File tree

ios/tunnel/tunnel_api.go

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ var netClient = &http.Client{
2727

2828
var ErrTunnelNotFound = errors.New("tunnel not found")
2929

30+
// ErrUnsupportedVersion is returned when a device's iOS version can never use
31+
// go-ios managed tunnels (tunnels only exist for iOS 17+). The TunnelManager
32+
// treats it as permanent and stops retrying such devices.
33+
var ErrUnsupportedVersion = errors.New("unsupported iOS version")
34+
3035
func CloseAgent() error {
3136
_, err := netClient.Get(fmt.Sprintf("http://%s:%d/shutdown", ios.HttpApiHost(), ios.HttpApiPort()))
3237
if err != nil {
@@ -322,17 +327,29 @@ type failedDevice struct {
322327
type TunnelManager struct {
323328
ts tunnelStarter
324329
dl deviceLister
330+
pr tunnelProber
325331
pm PairRecordManager
326332
mux sync.Mutex
327333
tunnels map[string]Tunnel
328334
// failedDevices tracks devices whose tunnel start failed (keyed by udid) so
329335
// UpdateTunnels can back off before retrying them.
330-
failedDevices map[string]failedDevice
336+
failedDevices map[string]failedDevice
337+
// unsupportedDevices tracks devices whose iOS version can never tunnel
338+
// (ErrUnsupportedVersion). They are skipped for the rest of the process so
339+
// the same warning is not logged every update cycle.
340+
unsupportedDevices map[string]bool
341+
// lastProbe tracks when each tunnel was last liveness-probed so probes run
342+
// at most once per probeInterval.
343+
lastProbe map[string]time.Time
344+
probeInterval time.Duration
331345
startTunnelTimeout time.Duration
332346
firstUpdateCompleted bool
333347
userspaceTUN bool
334348
closeOnce sync.Once
335349
portOffset int
350+
// productVersion resolves a device's iOS version; a seam so tests can run
351+
// without a device. Defaults to ios.GetProductVersion.
352+
productVersion func(ios.DeviceEntry) (*semver.Version, error)
336353
// udidFilter, when non-empty, restricts the manager to a single device so
337354
// you can run one isolated tunnel agent per device.
338355
udidFilter string
@@ -364,14 +381,19 @@ func newTunnelManager(pm PairRecordManager, userspaceTUN bool, udidFilter string
364381
return &TunnelManager{
365382
ts: manualPairingTunnelStart{},
366383
dl: deviceList{},
384+
pr: rsdProber{},
367385
pm: pm,
368386
tunnels: map[string]Tunnel{},
369387
failedDevices: map[string]failedDevice{},
388+
unsupportedDevices: map[string]bool{},
389+
lastProbe: map[string]time.Time{},
390+
probeInterval: defaultProbeInterval,
370391
startTunnelTimeout: 10 * time.Second,
371392
userspaceTUN: userspaceTUN,
372393
udidFilter: udidFilter,
373394
basePort: basePort,
374395
portOffset: 1,
396+
productVersion: ios.GetProductVersion,
375397
}
376398
}
377399

@@ -410,6 +432,8 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
410432
maps.Copy(localTunnels, m.tunnels)
411433
localFailed := map[string]failedDevice{}
412434
maps.Copy(localFailed, m.failedDevices)
435+
localUnsupported := map[string]bool{}
436+
maps.Copy(localUnsupported, m.unsupportedDevices)
413437
m.mux.Unlock()
414438

415439
devices, err := m.dl.ListDevices()
@@ -433,11 +457,32 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
433457
currentUDIDs[d.Properties.SerialNumber] = true
434458
}
435459

460+
// Liveness-probe existing tunnel records of still-connected devices: a
461+
// tunnel can die without a usbmux disconnect (quick reboot, transport
462+
// 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.
466+
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+
}
476+
}
477+
436478
for _, d := range devices.DeviceList {
437479
udid := d.Properties.SerialNumber
438480
if m.udidFilter != "" && udid != m.udidFilter {
439481
continue
440482
}
483+
if localUnsupported[udid] {
484+
continue
485+
}
441486
if _, exists := localTunnels[udid]; exists {
442487
continue
443488
}
@@ -456,6 +501,16 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
456501
}
457502
t, err := m.startTunnel(ctx, d)
458503
if err != nil {
504+
if errors.Is(err, ErrUnsupportedVersion) {
505+
// The device can never tunnel on its current iOS version, so
506+
// retrying would only repeat the same warning every cycle. Log
507+
// once at info and skip the device for the rest of the process.
508+
golog.Info("device iOS version does not support tunnels, skipping it from now on", "module", logModule, "udid", udid, "error", err)
509+
m.mux.Lock()
510+
m.unsupportedDevices[udid] = true
511+
m.mux.Unlock()
512+
continue
513+
}
459514
golog.Warn("failed to start tunnel", "module", logModule, "udid", udid, "error", err)
460515
m.mux.Lock()
461516
m.failedDevices[udid] = failedDevice{lastAttempt: time.Now(), failCount: m.failedDevices[udid].failCount + 1}
@@ -464,6 +519,10 @@ func (m *TunnelManager) UpdateTunnels(ctx context.Context) error {
464519
}
465520
m.mux.Lock()
466521
delete(m.failedDevices, udid)
522+
if m.lastProbe != nil {
523+
// A fresh tunnel is known-alive; defer its first probe by a full interval.
524+
m.lastProbe[udid] = time.Now()
525+
}
467526
localTunnels[udid] = t
468527
m.tunnels[udid] = t
469528
m.mux.Unlock()
@@ -500,6 +559,21 @@ func shouldSkipDevice(d ios.DeviceEntry, failed map[string]failedDevice, now tim
500559
return false
501560
}
502561

562+
// shouldProbe reports whether the tunnel for udid is due for a liveness probe
563+
// and, if so, records the attempt so probes run at most once per probeInterval.
564+
func (m *TunnelManager) shouldProbe(udid string, now time.Time) bool {
565+
if m.pr == nil || m.probeInterval <= 0 {
566+
return false
567+
}
568+
m.mux.Lock()
569+
defer m.mux.Unlock()
570+
if last, ok := m.lastProbe[udid]; ok && now.Sub(last) < m.probeInterval {
571+
return false
572+
}
573+
m.lastProbe[udid] = now
574+
return true
575+
}
576+
503577
// failedDeviceBackoff returns how long to wait before retrying a device after
504578
// failCount consecutive failures: 30s, 60s, 120s, 240s, capped at 5 minutes.
505579
func failedDeviceBackoff(failCount int) time.Duration {
@@ -523,6 +597,7 @@ func (m *TunnelManager) RemoveTunnel(ctx context.Context, serialNumber string) e
523597
if exists {
524598
delete(m.tunnels, serialNumber)
525599
}
600+
delete(m.lastProbe, serialNumber)
526601
m.mux.Unlock()
527602

528603
if !exists {
@@ -536,6 +611,7 @@ func (m *TunnelManager) stopTunnel(t Tunnel) error {
536611
m.mux.Lock()
537612
golog.Info("stopping tunnel", "module", logModule, "udid", t.Udid)
538613
delete(m.tunnels, t.Udid)
614+
delete(m.lastProbe, t.Udid)
539615
m.mux.Unlock()
540616

541617
return t.Close()
@@ -545,7 +621,11 @@ func (m *TunnelManager) startTunnel(ctx context.Context, device ios.DeviceEntry)
545621
golog.Info("start tunnel", "module", logModule, "udid", device.Properties.SerialNumber)
546622
startTunnelCtx, cancel := context.WithTimeout(ctx, m.startTunnelTimeout)
547623
defer cancel()
548-
version, err := ios.GetProductVersion(device)
624+
versionOf := m.productVersion
625+
if versionOf == nil {
626+
versionOf = ios.GetProductVersion
627+
}
628+
version, err := versionOf(device)
549629
if err != nil {
550630
return Tunnel{}, fmt.Errorf("startTunnel: failed to get device version: %w", err)
551631
}
@@ -586,6 +666,40 @@ type deviceLister interface {
586666
ListDevices() (ios.DeviceList, error)
587667
}
588668

669+
// tunnelProber checks whether an existing tunnel record still reaches the
670+
// device, so the TunnelManager can tear down and rebuild dead tunnels.
671+
type tunnelProber interface {
672+
Probe(t Tunnel) error
673+
}
674+
675+
// 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.
678+
const defaultProbeInterval = 30 * time.Second
679+
680+
// probeDialTimeout bounds the liveness probe's TCP connect. A healthy tunnel
681+
// answers in milliseconds; a dead-but-still-routed one swallows the SYN, so a
682+
// short timeout is enough to tell them apart without stalling UpdateTunnels.
683+
const probeDialTimeout = 5 * time.Second
684+
685+
// rsdProber is the default tunnelProber: a short-timeout TCP dial of the
686+
// tunnel's RSD endpoint over the kernel TUN route.
687+
type rsdProber struct {
688+
}
689+
690+
func (rsdProber) Probe(t Tunnel) error {
691+
if t.UserspaceTUN {
692+
// The userspace forwarder listens on localhost and accepts connects no
693+
// matter what state the device is in, so a dial proves nothing here.
694+
return nil
695+
}
696+
conn, err := ios.DialTunnelTCPWithTimeout(fmt.Sprintf("[%s]:%d", t.Address, t.RsdPort), probeDialTimeout)
697+
if err != nil {
698+
return err
699+
}
700+
return conn.Close()
701+
}
702+
589703
type manualPairingTunnelStart struct {
590704
}
591705

@@ -602,11 +716,11 @@ func (m manualPairingTunnelStart) StartTunnel(ctx context.Context, device ios.De
602716
}
603717
if version.Major() >= 17 {
604718
if userspaceTUN {
605-
return Tunnel{}, errors.New("manualPairingTunnelStart: userspaceTUN not supported for iOS >=17 and < 17.4")
719+
return Tunnel{}, fmt.Errorf("manualPairingTunnelStart: userspaceTUN not supported for iOS >=17 and < 17.4: %w %s", ErrUnsupportedVersion, version.String())
606720
}
607721
return ManualPairAndConnectToTunnel(ctx, device, p)
608722
}
609-
return Tunnel{}, fmt.Errorf("manualPairingTunnelStart: unsupported iOS version %s", version.String())
723+
return Tunnel{}, fmt.Errorf("manualPairingTunnelStart: %w %s", ErrUnsupportedVersion, version.String())
610724
}
611725

612726
type deviceList struct {

0 commit comments

Comments
 (0)