Why Your iOS VPN App Shows Disconnected While the Tunnel Is Up
Your iOS app controls the tunnel but does not own it, and the manager's state is cached. Load preferences first, then read status and interface counters.
Your app says disconnected, the menu bar says connected, and traffic is clearly flowing. That gap is not a race condition you can fix with a retry: the app and the tunnel are separate processes, and the object your code reads is a cached view of a configuration the system stores. Apple's Personal VPN surface is a control API, not a data path, and most of its odd behavior falls out of that one fact. Here is what actually happens under each call.
The manager object is a cached view, not the tunnel
NETunnelProviderManager is populated from a preferences store that the system owns, so its properties mean nothing until loadFromPreferences completes. Read connection.status before that and you get a default, which is why the first status refresh after a relaunch reports disconnected while the tunnel is up. Load first, then read the status and the protocol configuration from the same completion handler. Observe later changes through NEVPNStatusDidChange instead of polling, and note that the notification also fires for connections started by On Demand, where no app call exists to return anything at all. The same applies to protocol settings: they are read from the loaded configuration, not from what the app passed in.
The tunnel itself lives in a Network Extension provider process, so the app can lose its own state without losing the connection. Anything the UI shows has to be rebuilt from the manager, not remembered from the last session.
Start order: save, load, then start
A provider-based connection needs three steps in order. Write the provider configuration and call saveToPreferences, load the manager so the saved configuration is the one you are holding, then start through NETunnelProviderSession.startVPNTunnel(). Skip the save and the start has nothing to launch; load before the save and the manager still reflects the old configuration.
The Boolean returned by startVPNTunnel only says that the request was accepted. Provider-level failures arrive later as a status change to disconnected, which you observe through the NEVPNStatusDidChange notification, and the notification handler has to re-read connection.status after loadFromPreferences because the cached value may still be the old one. Request-level failures come back immediately from the throwing form of the call, or through the error out-parameter of startVPNTunnelAndReturnError:. Code that treats the return value as the outcome reports a connection that never established. The other silent failure is a provider bundle identifier that does not match the installed extension: the request is rejected, and nothing about the message points at the identifier. If the identifier is wrong the request usually fails at once, before any provider log line appears.
Reloading preferences after a start is what makes status screens correct. The manager does not push you a new configuration when the system changes it.
Trust material arrives by profile, never from app code
An app has no supported way to add a root certificate to the iOS trust store, so a Personal VPN configuration can only reference material the device already trusts: a chain established by an installed profile, by device management, or by a CA the system trusts for other reasons. An app that mints its own CA on first launch and expects the system to honor it will fail validation inside the provider, where the app's UI cannot see the reason.
Treat that boundary as the design constraint it is and ship the trust anchor with the enrollment profile. Keep VPN authentication separate from the trust decision, because they fail in different places and are debugged by different people.
Counters come from the interface, not from the VPN API
The manager exposes status and configuration, and nothing that counts bytes per tunnel. If you need usage numbers, call getifaddrs() and read if_data for the relevant interface: en0 for Wi-Fi, the pdp_ip interfaces for cellular, and a utun device for the tunnel itself. The utun counters describe inner traffic and include protocol overhead.
Those figures are estimates, not per-app accounting, and they reset when the interface is recreated. Label them that way in the UI, or someone will use them to bill a customer. If you need per-app numbers, you need a different mechanism entirely, not a better reading of these fields.
Cleanup belongs in stopVPNTunnel, not in termination
applicationWillTerminate gives you a handful of seconds before the system may kill the process, which is not a reliable place to tear down a connection. Send the stop to stopVPNTunnel() or handle it in the provider's stop callback, and keep the manager or configuration identifier you started so the stop targets that connection instead of every configuration on the device.
Store that identifier alongside the profile it belongs to, since a manager object does not record which configuration the user selected. And remember that a connection brought up by On Demand has no app call behind it at all, so a status screen still has to load preferences before it draws anything.