Description
On Linux, removing the last NetworkAddressChanged / NetworkAvailabilityChanged subscriber can deadlock permanently against the netlink reader loop. Two threads form a cycle:
Unsubscriber — the remove accessor takes lock (s_gate) and, finding both subscriber collections empty, calls CloseSocket() → Socket.Dispose(). SafeSocketHandle.CloseAsIs then spins in while (!_released) { canceledOperations |= TryUnblockSocket(abortive); sw.SpinOnce(); } waiting for the last SafeHandle reference to be released. It holds s_gate for the entire spin.
Netlink reader — ReadEventsAsync is inside Interop.Sys.ReadEvents(socket.SafeHandle, &ProcessEvent), so it holds that reference, and the ProcessEvent callback's first act is lock (s_gate). The source comments on exactly this reference:
It's safe to compare raw handle values because ProcessEvents gets called from ReadEvents which holds a reference on the SafeHandle.
Neither side can progress, ever. TryUnblockSocket cannot break it — the reader is not blocked in a syscall, it is parked on a managed monitor.
Relevant code (permalinks pinned to 8ffe515):
The current Socket-based design dates from #64614.
Why this is worse than a single hung call
The unsubscribe frequently runs on the finalizer thread, via HttpConnectionPoolManager+NetworkChangeCleanup.Finalize(). When it deadlocks there, the finalizer thread is gone for the life of the process:
- every
GC.WaitForPendingFinalizers() anywhere in the process blocks forever;
- no finalizer ever runs again, so
SafeHandles, sockets and file handles stop being reclaimed.
In our case 13 of 22 threads ended up parked in GC.WaitForPendingFinalizers() behind that one finalizer, and the process stopped doing useful work at all. It presented as an unexplained stall, not as an error.
Actual behaviour
From dotnet-dump analyze --command clrthreads on a heap dump of the stalled process:
Thread (finalizer):
System.Private.CoreLib!System.Threading.Thread.Sleep(int32)
System.Private.CoreLib!System.Threading.SpinWait.SpinOnceCore(int32)
System.Net.Sockets!System.Net.Sockets.Socket.Dispose(bool)
System.Net.Sockets!System.Net.Sockets.Socket.Dispose()
System.Net.NetworkInformation!System.Net.NetworkInformation.NetworkChange.CloseSocket()
System.Net.NetworkInformation!System.Net.NetworkInformation.NetworkChange.remove_NetworkAddressChanged(...)
System.Net.Http!System.Net.Http.HttpConnectionPoolManager+NetworkChangeCleanup.Finalize()
System.Private.CoreLib!System.GC.RunFinalizers()
Thread (threadpool):
System.Private.CoreLib!System.Threading.Monitor.Enter_Slowpath(class System.Object)
System.Private.CoreLib!System.Threading.Monitor.Enter(class System.Object,bool&)
System.Net.NetworkInformation!System.Net.NetworkInformation.NetworkChange.ProcessEvent(int,value class NetworkChangeKind)
System.Net.NetworkInformation!Interop+Sys.ReadEvents(class System.Runtime.InteropServices.SafeHandle,fnptr void(int,value class NetworkChangeKind))
System.Net.NetworkInformation!System.Net.NetworkInformation.NetworkChange+<ReadEventsAsync>d__29.MoveNext()
...
Plus 13 further threads blocked in System.GC.WaitForPendingFinalizers().
Expected behaviour
Unsubscribing the last handler completes, whether or not a netlink event is in flight.
Reproduction steps
No minimal repro — it is a race, hit in CI rather than constructed. The two conditions that make it reachable:
- Repeatedly drive the subscriber count to zero, since each 1→0 transition is another
CloseSocket(). Creating and releasing many SocketsHttpHandlers does this implicitly, because each pool manager subscribes and unsubscribes from its finalizer.
- Generate address-change events continuously, so the reader is usually mid-dispatch — e.g.
ip link add dummy0 type dummy plus ip addr add / ip addr del in a loop, or heavy container churn (every veth pair is an event).
Our occurrence was an integration test suite: several hundred host boots (so several hundred SocketsHttpHandlers created and finalized) while 12 Docker containers were being created and destroyed concurrently.
Workaround
Hold one NetworkAddressChanged subscription for the lifetime of the process. Both CloseSocket() call sites are guarded by "both subscriber collections are now empty", so a subscriber that is never removed makes them unreachable and the window never opens.
Possible direction for a fix
Offered tentatively — this is a reading of the code, not something I have built or tested.
Avoid disposing while holding s_gate: capture the socket and null the field under the lock, then dispose outside it. ProcessEvent already re-checks Socket != null && socket == Socket.Handle under the lock and would no-op, and ReadEventsAsync already catches ObjectDisposedException and SocketError.OperationAborted.
Configuration
- .NET 10.0.10, linux-x64
- Ubuntu 24.04.1 LTS, x64 VM
Regression?
Not known to be. The shape appears long-standing and is present on main.
Description
On Linux, removing the last
NetworkAddressChanged/NetworkAvailabilityChangedsubscriber can deadlock permanently against the netlink reader loop. Two threads form a cycle:Unsubscriber — the remove accessor takes
lock (s_gate)and, finding both subscriber collections empty, callsCloseSocket()→Socket.Dispose().SafeSocketHandle.CloseAsIsthen spins inwhile (!_released) { canceledOperations |= TryUnblockSocket(abortive); sw.SpinOnce(); }waiting for the lastSafeHandlereference to be released. It holdss_gatefor the entire spin.Netlink reader —
ReadEventsAsyncis insideInterop.Sys.ReadEvents(socket.SafeHandle, &ProcessEvent), so it holds that reference, and theProcessEventcallback's first act islock (s_gate). The source comments on exactly this reference:Neither side can progress, ever.
TryUnblockSocketcannot break it — the reader is not blocked in a syscall, it is parked on a managed monitor.Relevant code (permalinks pinned to
8ffe515):CloseSocket()call underlock (s_gate),NetworkAddressChangedremove accessorCloseSocket()call underlock (s_gate),NetworkAvailabilityChangedremove accessorCloseSocket()→Socket.Dispose()ProcessEventtakinglock (s_gate)from insideReadEventsSafeSocketHandle.CloseAsIsspinThe current
Socket-based design dates from #64614.Why this is worse than a single hung call
The unsubscribe frequently runs on the finalizer thread, via
HttpConnectionPoolManager+NetworkChangeCleanup.Finalize(). When it deadlocks there, the finalizer thread is gone for the life of the process:GC.WaitForPendingFinalizers()anywhere in the process blocks forever;SafeHandles, sockets and file handles stop being reclaimed.In our case 13 of 22 threads ended up parked in
GC.WaitForPendingFinalizers()behind that one finalizer, and the process stopped doing useful work at all. It presented as an unexplained stall, not as an error.Actual behaviour
From
dotnet-dump analyze --command clrthreadson a heap dump of the stalled process:Plus 13 further threads blocked in
System.GC.WaitForPendingFinalizers().Expected behaviour
Unsubscribing the last handler completes, whether or not a netlink event is in flight.
Reproduction steps
No minimal repro — it is a race, hit in CI rather than constructed. The two conditions that make it reachable:
CloseSocket(). Creating and releasing manySocketsHttpHandlers does this implicitly, because each pool manager subscribes and unsubscribes from its finalizer.ip link add dummy0 type dummyplusip addr add/ip addr delin a loop, or heavy container churn (every veth pair is an event).Our occurrence was an integration test suite: several hundred host boots (so several hundred
SocketsHttpHandlers created and finalized) while 12 Docker containers were being created and destroyed concurrently.Workaround
Hold one
NetworkAddressChangedsubscription for the lifetime of the process. BothCloseSocket()call sites are guarded by "both subscriber collections are now empty", so a subscriber that is never removed makes them unreachable and the window never opens.Possible direction for a fix
Offered tentatively — this is a reading of the code, not something I have built or tested.
Avoid disposing while holding
s_gate: capture the socket and null the field under the lock, then dispose outside it.ProcessEventalready re-checksSocket != null && socket == Socket.Handleunder the lock and would no-op, andReadEventsAsyncalready catchesObjectDisposedExceptionandSocketError.OperationAborted.Configuration
Regression?
Not known to be. The shape appears long-standing and is present on
main.