@@ -27,6 +27,11 @@ var netClient = &http.Client{
2727
2828var 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+
3035func 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 {
322327type 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.
505579func 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+
589703type 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
612726type deviceList struct {
0 commit comments