fix(sdk): auto-recover tunnel after macOS sleep/wake cycle
Hee Sung Son committed
Apr 11, 2026 at 07:38 UTC
2295dbfdea51634486d09f68c11dbf96ce239cc7
3 files changed
+116
-14
portal/transport/stream_client.go
+9
@@ -47,10 +47,19 @@ func (s *ClientStream) RunLoop(
47
open func(context.Context) (net.Conn, error),
48
currentTLSConfig func() *tls.Config,
49
retry func(context.Context, string, error, int) bool,
50
+ resetRetries <-chan struct{},
51
) {
52
var retries int
53
54
for {
55
+ // Drain any pending reset signals (e.g. from system wake) before
56
+ // evaluating retry budget. This is non-blocking so it never stalls.
57
+ select {
58
+ case <-resetRetries:
59
+ retries = 0
60
+ default:
61
+ }
62
+
63
claimed, err := s.runSession(ctx, open, currentTLSConfig)
64
switch {
65
case err == nil:
sdk/api_client.go
+12
@@ -85,6 +85,18 @@ func (a *apiClient) close() {
85
}
86
}
87
88
+// resetTransport tears down the cached HTTP client and TLS config so the next
89
+// API call creates fresh TCP connections. Call this after detecting a system
90
+// sleep/wake cycle where pooled connections are almost certainly dead.
91
+func (a *apiClient) resetTransport() {
92
+ if a == nil {
93
+ return
94
+ }
95
+ a.close()
96
+ a.httpClient = nil
97
+ a.rawTLSConfig = nil
98
+}
99
+
100
func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, error) {
101
if err := a.ensureHTTPClient(ctx); err != nil {
102
return types.RegisterResponse{}, err
sdk/listener.go
+95
-14
@@ -57,6 +57,13 @@ type Listener struct {
57
closeOnce sync.Once
58
registerOnce sync.Once
59
60
+ // wakeBroadcast is closed and replaced each time the renew loop detects
61
+ // a system sleep/wake cycle. Stream RunLoops select on this channel to
62
+ // reset their retry counters so they don't exhaust budget on stale-conn
63
+ // errors that are really caused by the OS suspending the process.
64
+ wakeBroadcast chan struct{}
65
+ wakeMu sync.Mutex
66
+
67
banMITM bool
68
tcpEnabled bool
69
identity types.Identity
@@ -86,19 +93,20 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
93
}
94
95
l := &Listener{
89
- doneCh: listenerCtx.Done(),
90
- cancel: cancel,
91
- api: api,
92
- registered: make(chan struct{}),
93
- retryCount: cfg.RetryCount,
94
- retryWait: retryWait,
95
- leaseTTL: leaseTTL,
96
- renewBefore: renewBefore,
97
- identity: api.identity.Copy(),
98
- metadata: cfg.Metadata.Copy(),
99
- banMITM: cfg.BanMITM,
100
- tcpEnabled: cfg.TCPEnabled,
101
- relaySet: cfg.relaySet,
96
+ doneCh: listenerCtx.Done(),
97
+ cancel: cancel,
98
+ api: api,
99
+ registered: make(chan struct{}),
100
+ wakeBroadcast: make(chan struct{}),
101
+ retryCount: cfg.RetryCount,
102
+ retryWait: retryWait,
103
+ leaseTTL: leaseTTL,
104
+ renewBefore: renewBefore,
105
+ identity: api.identity.Copy(),
106
+ metadata: cfg.Metadata.Copy(),
107
+ banMITM: cfg.BanMITM,
108
+ tcpEnabled: cfg.TCPEnabled,
109
+ relaySet: cfg.relaySet,
110
}
111
l.mitmManager = newMITMManager(listenerCtx, l)
112
l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
@@ -138,6 +146,7 @@ func (l *Listener) runStartup(ctx context.Context, readyTarget int) {
146
return l.tlsConfig
147
},
148
l.retryOrClose,
149
+ l.wakeChannel(),
150
)
151
}
152
go l.runRenewLoop(ctx)
@@ -387,6 +396,24 @@ func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool)
396
}, true
397
}
398
399
+// notifyWake closes the current wakeBroadcast channel (waking all stream
400
+// RunLoops that select on it) and replaces it with a fresh channel.
401
+func (l *Listener) notifyWake() {
402
+ l.wakeMu.Lock()
403
+ ch := l.wakeBroadcast
404
+ l.wakeBroadcast = make(chan struct{})
405
+ l.wakeMu.Unlock()
406
+ close(ch)
407
+}
408
+
409
+// wakeChannel returns the current wake broadcast channel. Stream RunLoops
410
+// select on this; when it is closed they reset their retry counters.
411
+func (l *Listener) wakeChannel() <-chan struct{} {
412
+ l.wakeMu.Lock()
413
+ defer l.wakeMu.Unlock()
414
+ return l.wakeBroadcast
415
+}
416
+
417
func (l *Listener) runRenewLoop(ctx context.Context) {
418
interval := l.leaseTTL / 2
419
if interval <= 0 {
@@ -399,10 +426,48 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
426
interval = 30 * time.Second
427
}
428
429
+ const wakeThreshold = 10 * time.Second
430
+
431
for {
432
+ // Round(0) strips the monotonic clock reading so that
433
+ // time.Since uses wall-clock time. The monotonic clock
434
+ // freezes during macOS sleep, so without this the elapsed
435
+ // duration would equal the timer interval, not real time.
436
+ before := time.Now().Round(0)
437
if !utils.SleepOrDone(ctx, interval) {
438
return
439
}
440
+ elapsed := time.Since(before)
441
+
442
+ // If the wall-clock jump is much larger than expected, the OS
443
+ // likely suspended the process (e.g. macOS lid close). The
444
+ // server-side lease is almost certainly expired, so skip the
445
+ // normal renew and go straight to re-registration.
446
+ if elapsed > interval+wakeThreshold {
447
+ log.Info().
448
+ Dur("expected", interval).
449
+ Dur("actual", elapsed).
450
+ Str("address", l.Address()).
451
+ Msg("system sleep/wake detected; resetting transport and re-registering")
452
+
453
+ l.api.resetTransport()
454
+ l.notifyWake()
455
+
456
+ var retries int
457
+ for {
458
+ if err := l.registerAndConfigure(ctx); err == nil {
459
+ break
460
+ } else if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
461
+ return
462
+ } else {
463
+ retries++
464
+ if !l.retryOrClose(ctx, "post-wake re-registration", err, retries) {
465
+ return
466
+ }
467
+ }
468
+ }
469
+ continue
470
+ }
471
472
var retries int
473
for {
@@ -526,7 +591,23 @@ func (l *Listener) retryOrClose(ctx context.Context, operation string, err error
591
Msg("operation failed; retrying")
592
}
593
529
- return utils.SleepOrDone(ctx, l.retryWait)
594
+ // Detect sleep/wake during the retry wait itself. If the OS
595
+ // suspended the process, the renew loop will be re-registering
596
+ // concurrently. Give it a moment to finish so the next attempt
597
+ // uses a fresh access token and transport.
598
+ // Round(0) forces wall-clock comparison (monotonic clock freezes
599
+ // during macOS sleep).
600
+ before := time.Now().Round(0)
601
+ ok := utils.SleepOrDone(ctx, l.retryWait)
602
+ if ok && time.Since(before) > l.retryWait+10*time.Second {
603
+ logger.Info().
604
+ Dur("expected", l.retryWait).
605
+ Dur("actual", time.Since(before)).
606
+ Msg("system sleep/wake detected during retry wait; pausing for re-registration")
607
+ // Wait briefly for the renew loop to complete re-registration.
608
+ utils.SleepOrDone(ctx, 3*time.Second)
609
+ }
610
+ return ok
611
}
612
613
type listenerAddr string