macOS: uncatchable NetworkInformationException from NetworkChange.OnAddressChanged aborts the process when an interface is removed during enumeration
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
On macOS, `NetworkChange.OnAddressChanged` can throw a `NetworkInformationException` that is **uncatchable by application code** and aborts the process. It is triggered by a TOCTOU race between interface enumeration and per-interface statistics lookup, so it fires when a network interface is torn down at the moment an address-change notification is being processed — common on hosts running VPN clients that create and destroy `utun` devices.
This is the macOS/BSD twin of the problem audited for Linux in #22689. That audit fixed the Linux path (`LinuxIPv4InterfaceProperties`, dotnet/corefx#32350); the BSD path was never covered and still throws.
### The chain
**1 — the race.** `SystemNative_GetNativeIPInterfaceStatistics` re-resolves an interface name against live kernel state:
```c
// src/native/libs/System.Native/pal_networkstatistics.c
unsigned int interfaceIndex = if_nametoindex(interfaceName);
if (interfaceIndex == 0)
{
// An invalid interface name was given (doesn't exist).
return -1;
}
```
The names come from a `getifaddrs()` **snapshot** taken earlier in `pal_interfaceaddresses.c`. Any interface removed between the snapshot and this call yields `-1`.
**2 — the throw.** `BsdNetworkInterface..ctor` converts that into an exception:
```csharp
if (Interop.Sys.GetNativeIPInterfaceStatistics(name, out nativeStats) == -1)
{
throw new NetworkInformationException(SR.net_PInvokeError);
}
```
**3 — the retry loop does not cover it.** `GetBsdNetworkInterfaces()` declares `const int MaxTries = 3`, but the exception path short-circuits before the retry is reached:
```csharp
int result = Interop.Sys.EnumerateInterfaceAddresses(...);
if (context._exceptions != null)
{
throw new NetworkInformationException(SR.net_PInvokeError, new AggregateException(context._exceptions));
}
if (result == 0) { /* ... */ return results; } // only this is retried
```
The per-callback `try/catch` blocks collect into `context->AddException`, so the exception does not escape the enumeration callbacks — it escapes the **outer** call.
**4 — why the process dies.** `NetworkChange.OnAddressChanged` is `[UnmanagedCallersOnly]`, invoked by CoreFoundation's run loop on the `.NET Network Address Change` thread, and calls `GetIsNetworkAvailable()` with no guard:
```csharp
// src/libraries/System.Net.NetworkInformation/src/System/Net/NetworkInformation/NetworkAddressChange.OSX.cs
if (availabilityChangedSubscribers != null)
{
bool isAvailable = NetworkInterface.GetIsNetworkAvailable(); // unguarded
```
An exception escaping an `[UnmanagedCallersOnly]` method cannot unwind through native frames, so the runtime aborts. Application code has no way to catch it — there is no `AppDomain.UnhandledException` opportunity that can prevent termination here.
This matches the guidance in #22689:
> `Interop.Sys.EnumerateInterfaceAddresses` starts a reverse PInvoke call, and everything in the call chain after this must never throw an exception, because they are uncatchable in a reverse PInvoke. […] In general, we should take a look at all of the calls that the reverse PInvoke makes to ensure there's no possibility of unhandled exceptions.
### Observed stack
```
Unhandled Exception
System.Net.NetworkInformation.NetworkInformationException (0x80004005): An error was encountered while querying information from the operating system.
---> System.AggregateException: One or more errors occurred. (An error was encountered while querying information from the operating system.)
---> System.Net.NetworkInformation.NetworkInformationException (0x80004005): An error was encountered while querying information from the operating system.
at System.Net.NetworkInformation.BsdNetworkInterface..ctor(String name, Int32 index)
at System.Net.NetworkInformation.BsdNetworkInterface.Context.GetOrCreate(Byte* pName, Int32 index)
at System.Net.NetworkInformation.BsdNetworkInterface.ProcessLinkLayerAddress(Void* pContext, Byte* ifaceName, LinkLayerAddressInfo* llAddr)
--- End of inner exception stack trace ---
at System.Net.NetworkInformation.BsdNetworkInterface.GetBsdNetworkInterfaces()
at System.Net.NetworkInformation.NetworkInterfacePal.GetIsNetworkAvailable()
at System.Net.NetworkInformation.NetworkChange.OnAddressChanged(IntPtr store, IntPtr changedKeys, IntPtr info)
```
Note the two nested `NetworkInformationException` levels with an `AggregateException` between them — that is the signature of step 3 above (`context._exceptions` non-null), which distinguishes it from a plain single-level enumeration failure.
### Reproduction
I do not have a minimal deterministic repro — this is an observed production crash plus the source analysis above. The conditions that make it likely:
- A long-running process that subscribes to `NetworkChange.NetworkAvailabilityChanged`.
- A macOS host with many interfaces, especially `utun` devices created/destroyed by VPN clients (WireGuard/Tailscale-style tunnels rebuild on rekey, endpoint migration, wake, and exit-node switches).
The failure is self-triggering: a tunnel appearing or disappearing *is itself* the `SCDynamicStore` change that fires `OnAddressChanged`, so the enumeration runs while the churn that invalidates it is still in progress.
On the affected host: 35 interfaces, 17 of them `utun`. `ifconfig -l` enumerates `… utun15 utun16 utun11` — out of numeric order, showing `utun11` was destroyed and recreated after `utun16` existed.
A synthetic repro would presumably be: subscribe to `NetworkAvailabilityChanged`, then repeatedly create and destroy `utun`/`feth` interfaces in a tight loop. I have not attempted it.
### Expected behaviour
An interface disappearing during enumeration is an ordinary, expected condition on a live system. It should not be able to terminate the process. Either:
- `GetBsdNetworkInterfaces()` should let the existing `MaxTries` retry cover the exception path (a disappearing interface is precisely the transient the retry appears to have been written for), and/or skip the vanished interface rather than throwing; or
- `NetworkChange.OnAddressChanged` should guard the `GetIsNetworkAvailable()` call, consistent with the "must never throw" requirement for reverse P/Invoke call chains stated in #22689.
### Actual behaviour
Process aborts. Uncatchable.
### Impact
For a long-running service this presents as an unexplained hard exit with no clean shutdown. In my case a media server died this way and stayed down for ten months before anyone noticed, because the process simply ceased to exist.
Since the only in-process control is whether anything subscribes to `NetworkAvailabilityChanged`, applications that need network-change notifications on macOS have no mitigation other than not using the API.
### Configuration
- .NET 8.0.14, `osx-arm64`, self-contained (also present in 9.x — see below)
- macOS 26.x, Apple Silicon (M4)
- Also reported on macOS 12.7.5 / x64 in openbullet/OpenBullet2#1079 with a frame-for-frame identical stack, so it is neither Apple-Silicon-specific nor macOS-26-specific.
### Regression?
No — this does not appear to be a regression. `NetworkAddressChange.OSX.cs` and `BsdNetworkInterface.cs` are functionally identical across `release/8.0`, `release/9.0`, `release/10.0` and `main`; the only differences I found are `using` ordering, collection expressions, and a `Haiku` platform attribute. I could not find an existing issue tracking the macOS variant.
### Other information
- #22689 — the 2017 audit of this exact hazard class, applied to Linux only.
- #12858 — an older macOS `EnumerateInterfaceAddresses` crash, different failure mode (`Internal CLR error`), closed.
- The same file family has a separate open crash report in `StopRunLoop` (SIGSEGV), suggesting the macOS `NetworkChange` path generally has not had much attention.
Happy to test a patch — I have a host that reproduces this naturally, though not on demand.
Contributor guide
Research direction
Start with src/native/libs/System.Native/pal_networkstatistics.c, BsdNetworkInterface.cs, and NetworkAddressChange.OSX.cs, then trace GetBsdNetworkInterfaces through the reverse P/Invoke callback. Compare the retry and exception paths with the Linux audit in #22689; done means interface churn during NetworkChange processing no longer aborts the process.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, macos
- Domain
- networking, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100