dotnet / dotnet/runtime

macOS: uncatchable NetworkInformationException from NetworkChange.OnAddressChanged aborts the process when an interface is removed during enumeration

Open
#132,235 2 comments 0 reactions 0 assignees View on GitHub
area-System.Net
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.