Android: TCP connections to tailnet peers are closed about 2 s after the phone joins Wi-Fi when mobile data stays connected in the background

Open
#22,336 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
android, go
Domain
mobile, networking

Research direction

Start with the Android VPN setup and network-change handling; the report points to VpnService.Builder.allowBypass() and Tailscale Android issue #790, but does not name repository files or tests. First determine whether Tailscale can change the socket network behavior without allowing apps to bypass the VPN, or whether this needs an Android report or documentation. Done means selecting and implementing a supported resolution for the described TCP disconnects.

Written by the indexing model from the issue text.

Description

bug connectivity OS-android
What is the issue?

On Android, a TCP connection that another app opens to a tailnet peer while the phone is on mobile data is closed about two seconds after the phone joins Wi-Fi. The app gets ECONNABORTED, and ssh prints client_loop: send disconnect: Broken pipe. The tunnel stays up and keeps its Android network (netId) across the switch, and a new connection works at once. A connection that opened on Wi-Fi survives a move to mobile data and back.

It happens when mobile data stays connected in the background after Wi-Fi takes over, which is what Developer options > Mobile data always active does. I expected a connection that the tunnel carries to survive a switch of the network under the tunnel.

Cause, as far as I could trace it

  1. At connect(), netd tags the socket with the netId of the default physical network unless the VPN for that UID is bypassable. NetworkController::getNetworkForConnectLocked returns the netId of the VPN only if (virtualNetwork && !virtualNetwork->isSecure()). The Tailscale app does not call VpnService.Builder.allowBypass(), so a socket to a tailnet peer has the netId of Wi-Fi or mobile data in its fwmark, although the routing rule of the VPN carries it. In Termux, getsockopt(SO_MARK) on a socket whose local address was the phone's Tailscale address returned 0x160: netId 352, which was mobile data. The VPN was netId 337.
  2. When the previous default network finishes lingering and stays connected, ConnectivityService removes FOREGROUND from it and changes its permission to NETWORK (getNetworkPermission and updateNetworkPermissions). Android then destroys every live TCP socket that has that netId in its fwmark and whose owner lacks the permission. In netd that is PhysicalNetwork::setPermission, which calls destroySocketsLackingPermission. On Android 17 the log shows the same step from InetDiagMessage.

So Android destroys the socket because of a network that the socket does not use.

logcat on Android 17 (times in UTC), followed by what the two apps reported:

03:02:36.318 ConnectivityService: Sending CONNECTED broadcast for type 1 [360 WIFI] isDefaultNetwork=true
03:02:36.667 Vpn     : Established by com.tailscale.ipn on tun1
03:02:38.267 ConnectivityService: handleLingerComplete for [352 CELLULAR]
03:02:38.267 ConnectivityService: Update capabilities for net 352 : -FOREGROUND
03:02:38.268 InetDiagMessage: Destroyed 2 sockets, proto=IPPROTO_TCP, family=AF_INET, states=14
03:02:38.269 InetDiagMessage: Destroyed live tcp sockets for lacking permission for netId:352 and permission:1 in 2ms
03:02:38.273 ssh:  client_loop: send disconnect: Broken pipe
03:02:38.282 node: read ECONNABORTED

In eight switches from mobile data to Wi-Fi, each connection that had opened on mobile data (ssh and Node) ended 1.8 to 2.0 s after Wi-Fi became the default network.

To confirm the cause, I tagged a socket with the network of the VPN before connect(). I called setNetworkForSocket in libnetd_client.so, which is the call behind android_setsocknetwork and Network.bindSocket. The fwmark of that socket has netId 337 and the explicit-selection bit. In six of those switches such a connection ran next to an untagged one, and it stayed open each time.

With Mobile data always active off, Android disconnects mobile data when Wi-Fi takes over, and the connection survived in the one switch that I measured. The cost is in the other direction: mobile data must connect after Wi-Fi drops, and a connection paused for 5.6 s in the one switch that I measured. With the setting on, that pause varied from under 2 s to 7.8 s. #22337 describes the cause.

A second, smaller case

A socket with no netId in its fwmark is destroyed each time the app establishes the tunnel again. Programs that make the connect system call themselves have such sockets, for example Go programs in Termux. From the same switch, with a Go program that held a connection to a tailnet peer:

03:02:36.639 gojni   : LinkChange: major, rebinding: ...
03:02:36.640 gojni   : updateTUN: changed
03:02:36.667 Vpn     : Established by com.tailscale.ipn on tun1
03:02:36.701 InetDiagMessage: Destroyed 1 sockets, proto=IPPROTO_TCP, family=AF_INET, states=14
03:02:36.702 InetDiagMessage: Destroyed live tcp sockets for local address /100.x.y.z, netIdRanges[[0, 99]], uidRanges:null in 1ms
03:02:36.707 go:   splice: software caused connection abort

Sockets that have a netId survive this step, which I take to be the effect of tailscale/tailscale-android#790.

What I am asking

I do not know whether Tailscale can fix the first case alone, because Android destroys the socket. These are the options that I can see:

  • Report it to Android. The destroy step selects sockets by the netId in the fwmark and does not consider that a VPN that is not bypassable carries them. You may have a better channel for that than a user report.
  • Offer a setting that calls allowBypass(). According to the comment above getNetworkForConnectLocked, netd then tags each socket with the netId of the VPN. The cost is that apps can bypass the tunnel.
  • Describe the behavior in the Android documentation, with Mobile data always active off as the workaround.
Steps to reproduce
  1. Connect Tailscale on an Android phone and turn on Developer options > Mobile data always active.
  2. Turn Wi-Fi off. In Termux, run ssh PEER 'while sleep 1; do date; done' for a tailnet peer.
  3. Turn Wi-Fi on. About two seconds after Wi-Fi becomes the default network, ssh ends with client_loop: send disconnect: Broken pipe.
  4. adb logcat | grep -E 'handleLingerComplete|InetDiagMessage' shows the lines above.
Are there any recent changes that introduced the issue?

It started when I turned on Mobile data always active.

OS

Android

OS version

Android 17 on a Samsung Galaxy Z Fold 8 (SM-F971U1)

Tailscale version

1.104.5

Other software

Termux with OpenSSH 10.6p2 and Node 24.22.2. Always-on VPN is on for Tailscale, and Block connections without VPN is off.

Bug report

No response

Dominant language
Go
Stars
38.8k
Forks
3.4k
Avg merge
2d 7h
Merged PRs (30d)
194

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from tailscale/tailscale

All issues in tailscale/tailscale

Similar issues

More Go issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.