libtailscale should handle app getting suspended
Author: boukCreated Sep 17, 2026Updated Sep 17, 2026
LabelsOS-iosbug
What is the issue?
We're using libtailscale in an iOS+Swift app. When the device is locked iOS will reclaim its socket after ~30s but libtailscale then never recovers. This behavior is documented here: https://developer.apple.com/library/archive/technotes/tn2277/_index.html#//apple_ref/doc/uid/DTS40010841-CH1-SUBSECTION3
I've let claude fable loose on our app and it implemented automatic reopening of the required sockets without restarting the whole wireguard VPN stuff, but I don't really understand this patch so I'm not going to submit it.
.patch--- /dev/null 1970-01-01 00:00:00 +0000
+++ b/net/netmon/ios_recovery_darwin_test.go
@@ -0,0 +1,73 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package netmon
+
+import (
+ "errors"
+ "os"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestRouteMonitorRecoversSocket(t *testing.T) {
+ opens := 0
+ m := &darwinRouteMon{logf: t.Logf}
+ m.openSocket = func() (*os.File, error) {
+ opens++
+ if opens == 1 {
+ return nil, syscall.EMFILE
+ }
+ r, w, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ w.Close() // EOF simulates a socket revoked during suspension
+ return r, nil
+ }
+ defer m.Close()
+ if _, err := m.Receive(); !errors.Is(err, syscall.EMFILE) {
+ t.Fatalf("open error: %v", err)
+ }
+ for cycle := 0; cycle < 2; cycle++ {
+ msg, err := m.Receive()
+ if err != nil || msg.ignore() {
+ t.Fatalf("missing recovery notification: %v, %v", msg, err)
+ }
+ if _, err := m.Receive(); err == nil {
+ t.Fatal("expected revoked socket error")
+ }
+ }
+ if opens != 3 {
+ t.Fatalf("opened %d sockets, want 3", opens)
+ }
+ m.Close()
+ if _, err := m.Receive(); !errors.Is(err, os.ErrClosed) {
+ t.Fatalf("Receive after Close: %v", err)
+ }
+ if opens != 3 {
+ t.Fatal("reopened after Close")
+ }
+}
+
+func TestRouteMonitorCloseInterruptsRead(t *testing.T) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Close()
+ m := &darwinRouteMon{logf: t.Logf, socket: r}
+ defer m.Close()
+ done := make(chan error, 1)
+ go func() { _, err := m.Receive(); done <- err }()
+ m.Close()
+ select {
+ case err := <-done:
+ if !errors.Is(err, os.ErrClosed) {
+ t.Fatalf("Receive: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Close did not interrupt Read")
+ }
+}
--- a/net/netmon/netmon_darwin.go
+++ b/net/netmon/netmon_darwin.go
@@ -5,7 +5,9 @@
import (
"fmt"
+ "io"
"net/netip"
+ "os"
"strings"
"sync"
@@ -31,36 +33,89 @@
func (unspecifiedMessage) ignore() bool { return false }
-func newOSMon(_ *eventbus.Bus, logf logger.Logf, _ *Monitor) (osMon, error) {
+func newRouteSocket() (*os.File, error) {
fd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
if err != nil {
return nil, err
}
- return &darwinRouteMon{
- logf: logf,
- fd: fd,
- }, nil
+ // Let the Go poller own the descriptor so Close interrupts a blocked read
+ // without racing a read against reuse of the descriptor number.
+ if err := unix.SetNonblock(fd, true); err != nil {
+ unix.Close(fd)
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), "route"), nil
}
+func newOSMon(_ *eventbus.Bus, logf logger.Logf, m *Monitor) (osMon, error) {
+ socket, err := newRouteSocket()
+ if err != nil {
+ return nil, err
+ }
+ return &darwinRouteMon{logf: logf, mon: m, socket: socket, openSocket: newRouteSocket}, nil
+}
+
type darwinRouteMon struct {
- logf logger.Logf
- fd int // AF_ROUTE socket
- buf [2 << 10]byte
- closeOnce sync.Once
+ logf logger.Logf
+ mon *Monitor // nil in tests
+ buf [2 << 10]byte
+ openSocket func() (*os.File, error)
+ mu sync.Mutex // protects socket and closed; never held during Read
+ socket *os.File
+ closed bool
}
func (m *darwinRouteMon) Close() error {
- var err error
- m.closeOnce.Do(func() {
- err = unix.Close(m.fd)
- })
- return err
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.closed = true
+ if m.socket != nil {
+ err := m.socket.Close()
+ m.socket = nil
+ return err
+ }
+ return nil
}
func (m *darwinRouteMon) Receive() (message, error) {
for {
- n, err := unix.Read(m.fd, m.buf[:])
- if err != nil {
+ m.mu.Lock()
+ if m.closed {
+ m.mu.Unlock()
+ return nil, os.ErrClosed
+ }
+ if m.socket == nil {
+ socket, err := m.openSocket()
+ m.socket = socket
+ m.mu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ m.logf("route monitor socket reopened")
+ // Route changes may have been lost while the socket was defunct,
+ // and iOS revokes every socket of the process together, so the
+ // callbacks run even when the interfaces look unchanged: their
+ // owners have sockets of their own to check.
+ if m.mon != nil {
+ m.mon.InjectEvent()
+ }
+ return unspecifiedMessage{}, nil
+ }
+ socket := m.socket
+ m.mu.Unlock()
+ n, err := socket.Read(m.buf[:])
+ if err != nil || n == 0 {
+ if err == nil {
+ err = io.EOF
+ }
+ // iOS can revoke sockets during suspension. Retrying Read on the
+ // same socket never recovers; the pump backs off before reopening.
+ m.mu.Lock()
+ if m.socket == socket {
+ socket.Close()
+ m.socket = nil
+ }
+ m.mu.Unlock()
return nil, err
}
msgs, err := func() (msgs []route.Message, err error) {
--- /dev/null 1970-01-01 00:00:00 +0000
+++ b/tsnet/loopback_listener_test.go
@@ -0,0 +1,121 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package tsnet
+
+import (
+ "errors"
+ "net"
+ "testing"
+ "time"
+)
+
+func newTestRevivingListener(t *testing.T) *revivingListener {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ l := newRevivingListener(ln, t.Logf)
+ t.Cleanup(func() { l.Close() })
+ return l
+}
+
+func acceptLoop(l *revivingListener) (conns chan net.Conn, done chan error) {
+ conns = make(chan net.Conn, 16)
+ done = make(chan error, 1)
+ go func() {
+ for {
+ c, err := l.Accept()
+ if err != nil {
+ done <- err
+ return
+ }
+ conns <- c
+ }
+ }()
+ return conns, done
+}
+
+func dialUntil(t *testing.T, addr net.Addr, deadline time.Duration) net.Conn {
+ t.Helper()
+ giveUp := time.Now().Add(deadline)
+ for {
+ c, err := net.DialTimeout("tcp", addr.String(), time.Second)
+ if err == nil {
+ return c
+ }
+ if time.Now().After(giveUp) {
+ t.Fatalf("dial %v: %v", addr, err)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+// A revoked socket refuses dials while Accept stays blocked on it. Closing
+// the socket behind the wrapper's back reproduces the refused dials.
+func TestRevivingListenerCheckRelistens(t *testing.T) {
+ l := newTestRevivingListener(t)
+ l.Check()
+ if l.ln == nil {
+ t.Fatal("healthy listener was dropped")
+ }
+ first := l.ln
+ first.Close()
+ if _, err := net.DialTimeout("tcp", l.addr.String(), time.Second); err == nil {
+ t.Fatal("dial to closed socket succeeded")
+ }
+ l.Check()
+ if l.ln == first {
+ t.Fatal("Check kept the dead socket")
+ }
+ conns, _ := acceptLoop(l)
+ c := dialUntil(t, l.addr, 2*time.Second)
+ defer c.Close()
+ select {
+ case a := <-conns:
+ a.Close()
+ case <-time.After(2 * time.Second):
+ t.Fatal("reopened listener did not accept")
+ }
+ if l.Addr().String() != l.addr.String() {
+ t.Fatalf("address changed to %v", l.Addr())
+ }
+}
+
+func TestRevivingListenerAcceptRelistens(t *testing.T) {
+ l := newTestRevivingListener(t)
+ conns, done := acceptLoop(l)
+ l.mu.Lock()
+ first := l.ln
+ l.mu.Unlock()
+ first.Close()
+ c := dialUntil(t, l.addr, 2*time.Second)
+ defer c.Close()
+ select {
+ case a := <-conns:
+ a.Close()
+ case err := <-done:
+ t.Fatalf("Accept exited: %v", err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("reopened listener did not accept")
+ }
+}
+
+func TestRevivingListenerClose(t *testing.T) {
+ l := newTestRevivingListener(t)
+ _, done := acceptLoop(l)
+ l.Close()
+ select {
+ case err := <-done:
+ if !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("Accept error = %v, want ErrClosed", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Accept did not return after Close")
+ }
+ l.Check()
+ if _, err := net.DialTimeout("tcp", l.addr.String(), time.Second); err == nil {
+ t.Fatal("Check reopened a closed listener")
+ }
+}
--- /dev/null 1970-01-01 00:00:00 +0000
+++ b/tsnet/loopback_listener.go
@@ -0,0 +1,96 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package tsnet
+
+import (
+ "net"
+ "sync"
+ "time"
+
+ "tailscale.com/types/logger"
+)
+
+// revivingListener is a loopback listener that outlives the OS revoking its
+// socket. iOS marks every socket of a suspended app defunct: connections to
+// the port are refused, but a defunct listening socket never becomes
+// readable, so Accept blocks on it forever. Check re-listens on the same
+// address when a dial to it fails; Accept re-listens when the socket errors.
+type revivingListener struct {
+ logf logger.Logf
+ addr net.Addr
+
+ mu sync.Mutex
+ ln net.Listener
+ closed bool
+}
+
+func newRevivingListener(ln net.Listener, logf logger.Logf) *revivingListener {
+ return &revivingListener{logf: logf, addr: ln.Addr(), ln: ln}
+}
+
+func (l *revivingListener) Accept() (net.Conn, error) {
+ for {
+ l.mu.Lock()
+ ln, closed := l.ln, l.closed
+ l.mu.Unlock()
+ if closed {
+ return nil, net.ErrClosed
+ }
+ c, err := ln.Accept()
+ if err == nil {
+ return c, nil
+ }
+ if !l.reopen(ln, err) {
+ time.Sleep(time.Second)
+ }
+ }
+}
+
+// Check dials the listener and re-listens when nothing answers.
+func (l *revivingListener) Check() {
+ l.mu.Lock()
+ ln, closed := l.ln, l.closed
+ l.mu.Unlock()
+ if closed {
+ return
+ }
+ c, err := net.DialTimeout("tcp", l.addr.String(), 2*time.Second)
+ if err == nil {
+ c.Close()
+ return
+ }
+ l.reopen(ln, err)
+}
+
+// reopen replaces old with a fresh socket on the same address, unless old
+// was already replaced or the listener is closed. It reports false when
+// the address could not be bound; old is closed either way.
+func (l *revivingListener) reopen(old net.Listener, cause error) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.closed || l.ln != old {
+ return true
+ }
+ old.Close()
+ ln, err := net.Listen("tcp", l.addr.String())
+ if err != nil {
+ l.logf("loopback listener %v failed (%v); re-listen failed: %v", l.addr, cause, err)
+ return false
+ }
+ l.ln = ln
+ l.logf("loopback listener %v reopened after: %v", l.addr, cause)
+ return true
+}
+
+func (l *revivingListener) Close() error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.closed {
+ return net.ErrClosed
+ }
+ l.closed = true
+ return l.ln.Close()
+}
+
+func (l *revivingListener) Addr() net.Addr { return l.addr }
--- a/tsnet/tsnet.go
+++ b/tsnet/tsnet.go
@@ -308,9 +308,14 @@
if err != nil {
return "", "", "", err
}
- s.loopbackListener = ln
+ lbl := newRevivingListener(ln, s.logf)
+ s.loopbackListener = lbl
+ // The OS revoking the socket shows up as a network change (the
+ // route monitor's socket goes with it); the listener's callers hold
+ // its address, so it has to come back on the same one.
+ s.netMon.RegisterChangeCallback(func(*netmon.ChangeDelta) { lbl.Check() })
- socksLn, httpLn := proxymux.SplitSOCKSAndHTTP(ln)
+ socksLn, httpLn := proxymux.SplitSOCKSAndHTTP(lbl)
// TODO: add HTTP proxy support. Probably requires factoring
// out the CONNECT code from tailscaled/proxy.go that uses
--- /dev/null 1970-01-01 00:00:00 +0000
+++ b/wgengine/magicsock/ios_recovery_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build darwin
+
+package magicsock
+
+import (
+ "bytes"
+ "errors"
+ "net"
+ "net/netip"
+ "syscall"
+ "testing"
+ "time"
+
+ wgconn "github.com/tailscale/wireguard-go/conn"
+ "tailscale.com/types/nettype"
+)
+
+type revokedPacketConn struct{ nettype.PacketConn }
+
+func (c *revokedPacketConn) ReadFromUDPAddrPort([]byte) (int, netip.AddrPort, error) {
+ return 0, netip.AddrPort{}, &net.OpError{Op: "read", Net: "udp4", Err: syscall.ENOTCONN}
+}
+
+func revokeUDP(c *Conn) *revokedPacketConn {
+ c.pconn4.mu.Lock()
+ defer c.pconn4.mu.Unlock()
+ revoked := &revokedPacketConn{c.pconn4.pconn}
+ c.pconn4.setConnLocked(revoked, "udp4", 1)
+ return revoked
+}
+
+func TestReceiveRecoversRevokedSocket(t *testing.T) {
+ c := newTestConn(t)
+ t.Cleanup(func() { c.Close() })
+ sender, err := net.ListenPacket("udp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sender.Close()
+ receive := c.receiveIPv4()
+ for cycle := 0; cycle < 2; cycle++ {
+ c.lastErrRebind.Store(time.Time{})
+ revoked := revokeUDP(c)
+ payload := bytes.Repeat([]byte{'x'}, 1024)
+ buffers := [][]byte{make([]byte, 2048)}
+ sizes := make([]int, 1)
+ done := make(chan error, 1)
+ go func() {
+ _, err := receive(buffers, sizes, make([]wgconn.Endpoint, 1))
+ done <- err
+ }()
+ deadline := time.Now().Add(5 * time.Second)
+ for c.pconn4.currentConn() == revoked && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if c.pconn4.currentConn() == revoked {
+ t.Fatal("receive did not rebind revoked socket")
+ }
+ addTestEndpoint(t, c, sender)
+ if _, err := sender.WriteTo(payload, c.pconn4.LocalAddr()); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("receiver exited after suspension: %v", err)
+ }
+ if !bytes.Equal(buffers[0][:sizes[0]], payload) {
+ t.Fatal("received incorrect payload")
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("receiver did not deliver packet after rebind")
+ }
+ }
+}
+
+func TestCloseDuringReceiveRecovery(t *testing.T) {
+ c := newTestConn(t)
+ defer c.Close()
+ revokeUDP(c)
+ c.lastErrRebind.Store(time.Now()) // force the throttled retry path
+ done := make(chan error, 1)
+ go func() {
+ _, err := c.receiveIPv4()([][]byte{make([]byte, 2048)}, make([]int, 1), make([]wgconn.Endpoint, 1))
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ t.Fatalf("receiver exited before Close: %v", err)
+ case <-time.After(250 * time.Millisecond):
+ }
+ c.Close()
+ select {
+ case err := <-done:
+ if !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("Close returned %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Close did not stop recovery")
+ }
+}
--- a/wgengine/magicsock/magicsock.go
+++ b/wgengine/magicsock/magicsock.go
@@ -1761,6 +1761,19 @@
if neterror.PacketWasTruncated(err) {
continue
}
+ // A revoked iOS socket is recoverable, but returning its
+ // non-temporary error makes WireGuard retire this receiver.
+ if ok, _ := shouldRebind(err); ok && !c.closing.Load() {
+ c.maybeRebindOnError(err)
+ // Rebind can fail or be throttled. Avoid spinning on
+ // the defunct socket, and let Close interrupt the wait.
+ select {
+ case <-c.connCtx.Done():
+ return 0, net.ErrClosed
+ case <-time.After(100 * time.Millisecond):
+ }
+ continue
+ }
return 0, err
}Steps to reproduce
Have an app with libtailscale, lock the phone, wait a bit, unlock the phone, libtailscale is broken.
Are there any recent changes that introduced the issue?
No response
OS
iOS
OS version
iOS 27.0
Tailscale version
No response
Other software
No response
Bug report
No response
Source: tailscale/tailscale