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